React Query
For years, React developers treated data fetched from an API as “global state,” storing it in Redux alongside UI state like “isModalOpen.” This was a mistake.
Server State is different from Client State:
- It is persisted remotely in a location you do not control.
- It requires asynchronous APIs for fetching and updating.
- It implies shared ownership and can be changed by other people without your knowledge.
- It can become “stale” if you’re not careful.
React Query (now TanStack Query) solves these problems by managing the lifecycle of your data fetching.
1. The Lifecycle of a Query
React Query manages queries through a specific lifecycle. Understanding these states is key to mastering the library.
Interactive: Query Lifecycle Simulator
Click the buttons to simulate fetching data and see how the state transitions.
2. Key Concepts
1. Stale-While-Revalidate
This is the magic of React Query. It allows your app to show “stale” (old) data instantly from the cache while fetching “fresh” data in the background. Once the fresh data arrives, the UI updates silently. No loading spinners for subsequent visits!
2. Caching
Every query is identified by a Query Key (e.g., ['todos', 1]). React Query handles deduplication automatically. If 5 components request the same key simultaneously, only one network request is sent.
3. Background Refetching
React Query assumes data on the server changes constantly. It automatically refetches when:
- The window is refocused.
- The network reconnects.
- The component mounts.
- The
staleTimehas expired.
3. Basic Usage
The useQuery hook is your primary tool.
import { useQuery } from '@tanstack/react-query';
interface Todo {
id: number;
title: string;
}
function TodoList() {
const { isPending, error, data } = useQuery({
queryKey: ['todos'],
queryFn: async (): Promise<Todo[]> => {
const response = await fetch('https://api.example.com/todos');
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
},
staleTime: 1000 * 60 * 5, // Data is fresh for 5 minutes
});
if (isPending) return <div>Loading...</div>;
if (error) return <div>An error has occurred: {error.message}</div>;
return (
<ul>
{data.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
);
}
[!WARNING] Don’t ignore keys! The
queryKeydependency array works likeuseEffect. If your fetch function depends on a variable (like a filter or ID), that variable must be in the query key:['todos', filter].
4. Mutations (Changing Data)
Fetching is easy; updating is hard. useMutation handles side effects (POST/PUT/DELETE).
import { useMutation, useQueryClient } from '@tanstack/react-query';
function AddTodo() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (newTodo: { title: string }) => {
return fetch('/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newTodo),
});
},
onSuccess: () => {
// Invalidate and refetch
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
return (
<button
onClick={() => mutation.mutate({ title: 'Do Laundry' })}
disabled={mutation.isPending}
>
{mutation.isPending ? 'Adding...' : 'Add Todo'}
</button>
);
}
5. Optimistic Updates
For a “snappy” feel, you can update the UI before the server responds.
This makes your app feel instant, even on slow networks. If the server fails, React Query rolls back the UI automatically.