

Mastering React Query: A Complete Guide from Setup to Advanced Usage
Why React Query?
Managing server state in React applications can be tricky. Redux and Context API work well for client-side state, but handling async data (fetching, caching, and synchronization) often leads to boilerplate code and complexity.

Enter React Query: a powerful library that simplifies data fetching, caching, and state synchronization with minimal setup. It eliminates the need for manual state management for server data, making your code cleaner and more efficient.
In this guide, we’ll cover:
✅ Initial Setup: Getting started with React Query
✅ Core Concepts: Fetching data with useQuery
✅ Advanced Techniques: Mutations, pagination, optimistic updates
✅ Best Practices: Optimizing performance and avoiding pitfalls
Let’s get started!
1. Getting started with React Query
a. Installation
First initialize your react project:
1npm create vite@latest react_query_app
This will give you suggestions:
- Select a framework ... select: React
- Select a variant ... select: Typescript
Now, add React Query to your project:
1npm install @tanstack/react-query
b. Configure the Query Client
Wrap your app with QueryClientProvider to enable React Query:
1import { StrictMode } from "react";
2import { createRoot } from "react-dom/client";
3import App from "./App.tsx";
4import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
5
6const queryClient = new QueryClient();
7
8createRoot(document.getElementById("root")!).render(
9 <StrictMode>
10 <QueryClientProvider client={queryClient}>
11 <App />
12 </QueryClientProvider>
13 </StrictMode>
14);
15
That’s it! You’re now ready to fetch and manage server data effortlessly.
2. Fetching Data with useQuery
a. Basic Data Fetching Example
The useQuery hook handles data fetching, caching, and loading states automatically.
1import { useQuery } from "@tanstack/react-query";
2
3type Post = {
4 userId: number;
5 id: number;
6 title: string;
7 body: string;
8};
9
10const fetchPosts = async () => {
11 const res = await fetch("https://jsonplaceholder.typicode.com/posts");
12 return res.json();
13};
14
15function App() {
16 const { data, isLoading, error } = useQuery<Post[]>({
17 queryKey: ["posts"], // Unique key for caching
18 queryFn: fetchPosts, // Function to fetch data
19 });
20
21 if (isLoading) return <div>Loading...</div>;
22 if (error) return <div>Error: {error.message}</div>;
23
24 return (
25 <ul>
26 {data?.map((post) => (
27 <li key={post.id}>{post.title}</li>
28 ))}
29 </ul>
30 );
31}
32
33export default App;
b. Key Benefits:
- Automatic Caching: Data is stored and reused efficiently.
- Background Updates: Stale data refreshes automatically.
- Built-in Loading & Error States: No need for manual state management.
3. Advanced Data Management
a. Pagination with useQuery
Implement smooth pagination using keepPreviousData:
1import { keepPreviousData, useQuery } from "@tanstack/react-query";
2import { useState } from "react";
3
4type Post = {
5 userId: number;
6 id: number;
7 title: string;
8 body: string;
9};
10
11const fetchPosts = async ({ limit, page }: { limit: string; page: number }) => {
12 const res = await fetch(
13 `https://jsonplaceholder.typicode.com/posts?_limit=${limit}&_page=${page}`
14 );
15 return res.json();
16};
17
18function App() {
19 const [limit, setLimit] = useState("10");
20 const [page, setPage] = useState(1);
21 const { data, isLoading, error } = useQuery<Post[]>({
22 queryKey: ["posts", limit, page], // Unique key for caching
23 queryFn: () => fetchPosts({ limit, page }), // Function to fetch data
24 });
25
26 if (isLoading) return <div>Loading...</div>;
27 if (error) return <div>Error: {error.message}</div>;
28
29 return (
30 <>
31 <ul>
32 {data?.map((post) => (
33 <li key={post.id}>{post.title}</li>
34 ))}
35 </ul>
36 <select onChange={(e) => setLimit(e.target.value)} value={limit}>
37 <option value="5">5</option>
38 <option value="10">10</option>
39 <option value="25">25</option>
40 <option value="50">50</option>
41 </select>
42 <button onClick={() => setPage((p) => Math.max(p - 1, 1))}>
43 Previous
44 </button>
45 <button onClick={() => setPage((p) => p + 1)}>Next</button>
46 </>
47 );
48}
49
50export default App;
51
b. Modifying Data with useMutation
Create, update, or delete data with mutations:
1import { useMutation } from "@tanstack/react-query";
2
3type User = {
4 name: string;
5 email: string;
6};
7
8const addUser = async (newUser: User) => {
9 const res = await fetch("https://jsonplaceholder.typicode.com/users", {
10 method: "POST",
11 body: JSON.stringify(newUser),
12 });
13 return res.json();
14};
15
16function App() {
17 const mutation = useMutation({
18 mutationFn: addUser,
19 });
20
21 const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
22 e.preventDefault();
23 mutation.mutate({ name: "John Doe", email: "john@example.com" });
24 };
25
26 return (
27 <form onSubmit={handleSubmit}>
28 <button type="submit" disabled={mutation.isPending}>
29 {mutation.isPending ? "Saving..." : "Add User"}
30 </button>
31 </form>
32 );
33}
34
35export default App;
36
c. useQuery vs useMutation
Think of these hooks as two different tools for different jobs when working with server data:
- useQuery is your data fetcher:
- It executes on page load
- It caches the result
- Refreshes automatically with configuration
- useMutation is your data manager:
- It does not execute on page load
- It does not cache the result
- Can only be called manually
d. Understanding invalidateQueries in React Query Mutations
When working with useMutation in React Query, one of the most important concepts is cache invalidation and invalidateQueries is your primary tool for keeping your UI in sync with server data after mutations.
e. What Does invalidateQueries Do?
InvalidateQueries marks cached data as stale and triggers automatic refetches for matching queries. This ensures your UI always displays fresh data after mutations
1import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
3type User = {
4 name: string;
5 email: string;
6};
7
8type UserWithId = User & { id: number };
9
10const fetchUsers = async () => {
11 const res = await fetch(`https://jsonplaceholder.typicode.com/users`);
12 return res.json();
13};
14
15const addUser = async (newUser: User) => {
16 const res = await fetch("https://jsonplaceholder.typicode.com/users", {
17 method: "POST",
18 body: JSON.stringify(newUser),
19 });
20 return res.json();
21};
22
23function App() {
24 const { data, isLoading } = useQuery<UserWithId[]>({
25 queryKey: ["users"],
26 queryFn: fetchUsers,
27 });
28 const queryClient = useQueryClient();
29 const mutation = useMutation({
30 mutationFn: addUser,
31 onSuccess: () => {
32 queryClient.invalidateQueries({ queryKey: ["users"] }); // Refresh user list
33 },
34 });
35
36 const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
37 e.preventDefault();
38 mutation.mutate({ name: "John Doe", email: "john@example.com" });
39 };
40
41 if (isLoading) return <p>Loading users...</p>;
42
43 return (
44 <form onSubmit={handleSubmit}>
45 {data?.map((user) => (
46 <article key={user.id}>
47 <h3>{user.name}</h3>
48 <span>{user.email}</span>
49 </article>
50 ))}
51 <button type="submit" disabled={mutation.isPending}>
52 {mutation.isPending ? "Saving..." : "Add User"}
53 </button>
54 </form>
55 );
56}
57
58export default App;
In this example, After the mutation succeeds, the users query will be invalidated triggering the fetchUsers query to display fresh data.
f. Optimistic Updates for a Smoother UX
Update the UI instantly while waiting for server confirmation:
1import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
3type User = {
4 name: string;
5 email: string;
6};
7
8type UserWithId = User & { id: number };
9
10const fetchUsers = async () => {
11 const res = await fetch(`https://jsonplaceholder.typicode.com/users`);
12 return res.json();
13};
14
15const addUser = async (newUser: User) => {
16 const res = await fetch("https://jsonplaceholder.typicode.com/users", {
17 method: "POST",
18 body: JSON.stringify(newUser),
19 });
20 return res.json();
21};
22
23function App() {
24 const { data, isLoading } = useQuery<UserWithId[]>({
25 queryKey: ["users"],
26 queryFn: fetchUsers,
27 });
28 const queryClient = useQueryClient();
29 const mutation = useMutation({
30 mutationFn: addUser,
31 onError: () => {
32 console.log(
33 "When this mutation succeeds, this message will be displayed"
34 );
35 },
36 onSuccess: () => {
37 console.log("When this mutation fails, this message will be displayed");
38 queryClient.invalidateQueries({ queryKey: ["users"] });
39 },
40 onSettled: () => {
41 console.log(
42 "Wether it fails or succeeds, When this mutation ends, this message will be displayed"
43 );
44 },
45 });
46
47 const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
48 e.preventDefault();
49 mutation.mutate({ name: "John Doe", email: "john@example.com" });
50 };
51
52 if (isLoading) return <p>Loading users...</p>;
53
54 return (
55 <form onSubmit={handleSubmit}>
56 {data?.map((user) => (
57 <article key={user.id}>
58 <h3>{user.name}</h3>
59 <span>{user.email}</span>
60 </article>
61 ))}
62 <button type="submit" disabled={mutation.isPending}>
63 {mutation.isPending ? "Saving..." : "Add User"}
64 </button>
65 </form>
66 );
67}
68
69export default App;
4. Best Practices for Optimal Performance
🚀 Use Descriptive Query Keys: Example: ['posts', { limit: '10', page: 1 }] for better cache management.
🚀 Adjust staleTime & cacheTime: Control how long data remains fresh or cached.
🚀 Prefetch Data for Better UX: Load data before the user needs it.
🚀 Use select for Derived Data: Transform API responses without altering cache.
🚀 Disable Unnecessary Refetches: Set refetchOnWindowFocus: false if not needed.
5. Conclusion
React Query revolutionizes how we handle server state in React apps. By leveraging its powerful caching, background updates, and mutation handling, you can build faster, more responsive applications with less code.
Explore features like Infinite Queries for seamless endless scrolling, Server-Side Rendering (SSR) Support for improved SEO and performance, and Custom Hooks to keep your logic reusable and your codebase clean. With React Query, managing async data becomes intuitive, reducing boilerplate and optimizing performance effortlessly.
Happy coding! 🚀