Modern web applications rely heavily on data fetching and synchronization. Whether you are building dashboards, social platforms, or analytics tools, your React components often need to display, update, and re-fetch remote data efficiently.
While tools like fetch() or Axios can handle simple requests, managing complex async states such as caching, revalidation, pagination, and background updates can quickly become challenging.
This is where React Query comes in.
React Query is a powerful library that abstracts away the complexities of data fetching and state management, making your React apps more performant, maintainable, and user-friendly.
In this post, we’ll deeply explore how React Query works, why it’s valuable, and how to implement it step-by-step with code examples and best practices.
What Is React Query?
React Query (now part of the TanStack Query ecosystem) is a data-fetching and state management library for React applications. It simplifies working with asynchronous data by handling caching, background updates, and re-fetching automatically.
Unlike Redux or Context, React Query isn’t about managing global UI state—it’s about managing server state: the data that comes from APIs and can change independently of your app.
Key Features of React Query
- Built-in caching – Stores API responses to prevent redundant requests.
- Automatic background refetching – Keeps your data fresh without manual reloading.
- Query invalidation – Automatically re-fetches data after mutations or updates.
- Pagination and infinite scrolling – Easily fetch paginated API data.
- Smart loading and error states – Simplifies handling of async status updates.
- Automatic retries – Retries failed requests with exponential backoff.
- Prefetching – Load data in advance before navigation for faster experiences.
Installing React Query
Install the core React Query package (TanStack Query for React):
npm install @tanstack/react-query
Or with yarn:
yarn add @tanstack/react-query
Setting Up the QueryClient
React Query requires a QueryClient instance to manage caching and global query behavior. Wrap your application with QueryClientProvider to make React Query available everywhere.
import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';
const queryClient = new QueryClient();
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
);
The QueryClient acts as a centralized data cache and request manager for your app.
Fetching Data with useQuery
The most common React Query hook is useQuery, which fetches and caches data automatically.
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
function UserList() {
const fetchUsers = async () => {
const res = await axios.get('https://jsonplaceholder.typicode.com/users');
return res.data;
};
const { data, isLoading, isError } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
});
if (isLoading) return <p>Loading...</p>;
if (isError) return <p>Error fetching users</p>;
return (
<ul>
{data.map(user => <li key={user.id}>{user.name}</li>)}
</ul>
);
}
export default UserList;
How It Works
queryKey– A unique identifier for the query. React Query uses it to cache results.queryFn– The function that fetches data.isLoading,isError,data– States automatically managed by React Query.
The first time the component mounts, React Query fetches the data and caches it. On re-renders, it reads from the cache instead of re-fetching unless the data becomes stale.
Understanding Query Caching
Caching is React Query’s most powerful feature. When you make a request with a queryKey, the response is stored in memory.
If you call useQuery(['users']) again elsewhere in the app, React Query instantly returns the cached data while optionally re-fetching in the background.
This leads to:
- Instant loading when revisiting pages.
- Reduced API calls.
- Better performance and user experience.
Cache Lifetime and Stale Time
React Query controls how long data stays in the cache through staleTime and cacheTime:
useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
staleTime: 30000, // Data considered fresh for 30 seconds
cacheTime: 600000, // Data stays in cache for 10 minutes
});
- staleTime: How long data remains “fresh.” No refetching occurs during this period.
- cacheTime: How long inactive queries are kept before being garbage-collected.
Automatic Background Refetching
React Query automatically re-fetches stale data when certain conditions occur:
- When the component remounts.
- When the browser window refocuses.
- When network connectivity is regained.
This keeps your data fresh without manual reloads.
You can control this behavior via configuration:
useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
});
Set them to false if you want to disable automatic re-fetching.
Handling Loading, Error, and Success States
React Query simplifies async state management by providing built-in flags.
const { isLoading, isError, error, data, isFetching } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
if (isLoading) return <p>Loading...</p>;
if (isError) return <p>Error: {error.message}</p>;
return (
<div>
{isFetching && <p>Updating data...</p>}
<ul>
{data.map(todo => <li key={todo.id}>{todo.title}</li>)}
</ul>
</div>
);
This eliminates the need for manually managing loading, error, or data states with useState or useEffect.
Using Query Variables
You can make dynamic queries by adding variables to your query keys.
function UserProfile({ userId }) {
const { data, isLoading } = useQuery({
queryKey: ['user', userId],
queryFn: () => axios.get(/api/users/${userId}).then(res => res.data),
});
if (isLoading) return <p>Loading user...</p>;
return <h2>{data.name}</h2>;
}
Here, React Query caches each unique user’s data separately because the userId changes the query key.
Mutations: Updating or Posting Data
Fetching data is only half the story. You also need to create, update, or delete data.
React Query provides the useMutation hook for this purpose.
import { useMutation, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
function AddUser() {
const queryClient = useQueryClient();
const addUserMutation = useMutation({
mutationFn: (newUser) => axios.post('/api/users', newUser),
onSuccess: () => {
queryClient.invalidateQueries(['users']);
},
});
const handleAdd = () => {
addUserMutation.mutate({ name: 'New User' });
};
return (
<button onClick={handleAdd}>
{addUserMutation.isPending ? 'Adding...' : 'Add User'}
</button>
);
}
How Mutations Work
- mutationFn – The function that performs the operation.
- onSuccess – Runs after the mutation succeeds, often used to invalidate cache and trigger a re-fetch.
- invalidateQueries – Refreshes data related to that query key.
Query Invalidation
After you modify server data (e.g., adding a new record), cached data may become outdated.
React Query’s invalidateQueries() method marks queries as stale, prompting them to refetch automatically.
queryClient.invalidateQueries(['users']);
This ensures your UI always displays the most recent data without manual state updates.
Prefetching Data
Prefetching loads data before the user navigates to a page, improving perceived performance.
import { useQueryClient } from '@tanstack/react-query';
function PrefetchUserButton() {
const queryClient = useQueryClient();
const handleHover = () => {
queryClient.prefetchQuery({
queryKey: ['user', 1],
queryFn: () => axios.get('/api/users/1').then(res => res.data),
});
};
return <button onMouseEnter={handleHover}>Hover to Prefetch User</button>;
}
Now, when the user later visits the user profile, the data will load instantly from the cache.
Paginated and Infinite Queries
React Query has built-in support for pagination and infinite scroll.
Example: Pagination
function PaginatedPosts() {
const [page, setPage] = useState(1);
const { data, isLoading } = useQuery({
queryKey: ['posts', page],
queryFn: () => axios.get(/api/posts?page=${page}).then(res => res.data),
keepPreviousData: true,
});
if (isLoading) return <p>Loading...</p>;
return (
<div>
{data.items.map(post => <p key={post.id}>{post.title}</p>)}
<button onClick={() => setPage(old => Math.max(old - 1, 1))}>Prev</button>
<button onClick={() => setPage(old => old + 1)}>Next</button>
</div>
);
}
Here, keepPreviousData: true retains old data while loading the next page, creating a smoother UX.
Background Updates
React Query automatically re-fetches data in the background after the data becomes stale or when triggered manually.
You can also manually refresh data:
const { refetch } = useQuery({
queryKey: ['comments'],
queryFn: fetchComments,
});
<button onClick={() => refetch()}>Refresh Comments</button>;
This refreshes the data on demand without a full component reload.
Automatic Retries and Error Recovery
If an API request fails (for example, due to a temporary network error), React Query automatically retries it.
useQuery({
queryKey: ['stats'],
queryFn: fetchStats,
retry: 3, // Retry up to 3 times
retryDelay: attempt => Math.min(attempt * 1000, 3000), // Exponential backoff
});
This improves reliability without extra error-handling logic.
Global Configuration
You can configure global defaults when creating your QueryClient.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 2,
refetchOnWindowFocus: false,
staleTime: 10000,
},
},
});
This standardizes query behavior across your app.
DevTools for React Query
React Query provides an excellent debugging tool—React Query DevTools—that visualizes queries, mutations, and cache status.
Install it:
npm install @tanstack/react-query-devtools
Then add it to your app:
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
You’ll see a panel showing real-time cache updates, queries, and their statuses—perfect for debugging.
Comparing React Query to Traditional Data Fetching
| Feature | Fetch/Axios + useEffect | React Query |
|---|---|---|
| Caching | Manual | Automatic |
| Refetching | Manual | Automatic |
| Background Updates | Hard to implement | Built-in |
| Error Handling | Custom logic | Automatic |
| Pagination | Manual logic | Built-in |
| State Management | useState/useReducer | useQuery/useMutation |
| Prefetching | Manual setup | Built-in |
React Query removes boilerplate and makes async state management nearly effortless.
Combining React Query with Axios
You can integrate Axios with React Query seamlessly for cleaner API management.
Example with Axios instance:
import axios from 'axios';
import { useQuery } from '@tanstack/react-query';
const api = axios.create({
baseURL: 'https://api.example.com',
});
function Products() {
const { data, isLoading } = useQuery({
queryKey: ['products'],
queryFn: () => api.get('/products').then(res => res.data),
});
if (isLoading) return <p>Loading...</p>;
return (
<ul>
{data.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
);
}
export default Products;
This combines the reliability of Axios with the power of React Query’s caching and refetching.
Handling Dependent Queries
Sometimes one query depends on another (e.g., fetching a user, then their posts).
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => axios.get(/users/${userId}).then(res => res.data),
});
const { data: posts } = useQuery({
queryKey: ['posts', user?.id],
queryFn: () => axios.get(/posts?userId=${user.id}).then(res => res.data),
enabled: !!user,
});
The second query runs only after the first one succeeds (enabled: !!user).
Optimistic Updates
For fast UX, React Query supports optimistic UI updates before a mutation completes.
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: async newTodo => {
await queryClient.cancelQueries(['todos']);
const previousTodos = queryClient.getQueryData(['todos']);
queryClient.setQueryData(['todos'], old => old.map(todo =>
todo.id === newTodo.id ? newTodo : todo
));
return { previousTodos };
},
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context.previousTodos);
},
onSettled: () => {
queryClient.invalidateQueries(['todos']);
},
});
This pattern improves perceived speed and keeps UI responsive during API calls.
Example: Complete CRUD with React Query
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
const api = axios.create({ baseURL: '/api' });
function TodoApp() {
const queryClient = useQueryClient();
const { data: todos, isLoading } = useQuery({
queryKey: ['todos'],
queryFn: () => api.get('/todos').then(res => res.data),
});
const addTodo = useMutation({
mutationFn: (todo) => api.post('/todos', todo),
onSuccess: () => queryClient.invalidateQueries(['todos']),
});
const deleteTodo = useMutation({
mutationFn: (id) => api.delete(/todos/${id}),
onSuccess: () => queryClient.invalidateQueries(['todos']),
});
if (isLoading) return <p>Loading...</p>;
return (
<div>
<button onClick={() => addTodo.mutate({ title: 'New Task' })}>Add</button>
<ul>
{todos.map(todo => (
<li key={todo.id}>
{todo.title}
<button onClick={() => deleteTodo.mutate(todo.id)}>Delete</button>
</li>
))}
</ul>
</div>
);
}
export default TodoApp;
This shows how simple CRUD operations can be managed elegantly using React Query.
When to Use React Query
Use React Query when:
- Your app interacts frequently with APIs.
- You want automatic caching, refetching, and background sync.
- You’re tired of managing
useEffectanduseStatefor async data. - You need pagination, optimistic updates, or prefetching.
Avoid React Query if:
- Your app only has minimal static data.
- You need to manage only UI state, not server state.
Leave a Reply