Difficulty: Intermediate
How does React Query work? Explain caching, mutations, and common data fetching patterns.
React Query, now officially TanStack Query, is one of those libraries that fundamentally changes how you think about data in React applications. Once you use it, going back to manual useEffect-based fetching feels like washing laundry by hand after discovering a washing machine. Let me explain what it does, why it exists, and the patterns that make it powerful.
The core problem React Query solves is managing server state. Before React Query, the standard approach was: create useState for data, loading, and error, write a useEffect that calls fetch, set loading, await the response, set data, catch errors. Repeat this in every component that needs data. This approach has a shocking number of hidden problems. No caching -- navigating away and back refetches everything. No deduplication -- three components requesting the same data make three identical network requests. Race conditions when responses arrive out of order. No background refetching, so data gets stale. No retry logic. React Query handles all of these automatically.
The fundamental concept is the query. A query is a declarative dependency on an async data source, identified by a unique key. You call useQuery with a query key and a query function, and React Query manages the entire lifecycle. The query key is an array that uniquely identifies the data -- for a user profile it might be ['user', userId], for filtered products it might be ['products', { category, sort }]. Keys are deeply compared, so the order of object properties does not matter.
The caching behavior is where the magic happens. Understanding staleTime versus gcTime is essential. staleTime controls how long cached data is considered fresh. While fresh, React Query returns cached data instantly with no network request. After staleTime expires, the data is stale. When stale data is accessed (component mounts, window refocuses, network reconnects), React Query returns the stale cached data immediately (so the user sees something right away) and simultaneously fetches fresh data in the background. When fresh data arrives, the cache updates and the component re-renders. This 'stale-while-revalidate' strategy makes apps feel fast.
gcTime (previously called cacheTime) is different. It controls how long inactive cached data stays in memory. When no components subscribe to a query, it becomes inactive. After gcTime passes, the data is garbage collected. If a component requests it again within the gcTime window, cached data is available. After gcTime, the query starts fresh.
The return value gives you everything: data, isLoading (first fetch, no cached data), isFetching (any fetch, including background), error, isError, isSuccess. The distinction between isLoading and isFetching is important. isLoading means show a skeleton. isFetching means data exists but we are checking for updates -- show a subtle refresh indicator.
The enabled option is incredibly useful. Set it to false and the query will not run. This enables dependent queries (query B waits for query A) and conditional queries (only fetch when a filter is selected).
Mutations handle data modification -- POST, PUT, PATCH, DELETE. useMutation gives you a mutate function, isPending for loading, and callbacks. The most important pattern is cache invalidation: after a mutation succeeds, invalidate related queries so they refetch. queryClient.invalidateQueries({ queryKey: ['products'] }) invalidates all queries starting with 'products'.
Optimistic updates are the advanced pattern interviewers love. Update the UI immediately before the server responds. If it succeeds, the UI is already correct. If it fails, roll back. The flow: in onMutate, cancel outgoing refetches, snapshot current cache, optimistically update cache, return snapshot. In onError, restore from snapshot. In onSettled, invalidate to refetch and ensure consistency.
Practical tips: design query keys carefully -- include every variable that affects the response. Set reasonable staleTime values based on data volatility. Use the select option to transform data at the query level. Install React Query DevTools for debugging cache state.
React Query is not a replacement for Zustand or Redux. It handles server state (API data). Client state (sidebar open, theme) should use Zustand, Context, or useState. The two complement each other perfectly.
import { useQuery, QueryClient, QueryClientProvider } from '@tanstack/react-query';
// Configure default options
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
retry: 2,
refetchOnWindowFocus: true,
},
},
});
// Wrap app with provider
function App() {
return (
<QueryClientProvider client={queryClient}>
<Router />
</QueryClientProvider>
);
}
// Query hook
function useProducts(category) {
return useQuery({
queryKey: ['products', category], // Cache key
queryFn: () => api.get(`/products?category=${category}`),
staleTime: 30 * 1000, // Override: fresh for 30s
enabled: !!category, // Don't fetch if no category
});
}
// Component usage
function ProductList({ category }) {
const { data, isLoading, error, isFetching } = useProducts(category);
if (isLoading) return <Skeleton />; // First load
if (error) return <Error error={error} />;
return (
<div>
{/* isFetching = background refetch happening */}
{isFetching && <RefreshIndicator />}
{data.map(p => <ProductCard key={p.id} product={p} />)}
</div>
);
}
Query keys uniquely identify cached data. staleTime controls freshness. isLoading is true only on first load, while isFetching is true during any fetch (including background refetches).
import { useMutation, useQueryClient } from '@tanstack/react-query';
function useCreateProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (newProduct) =>
api.post('/products', newProduct),
onSuccess: (data) => {
// Invalidate and refetch products list
queryClient.invalidateQueries({ queryKey: ['products'] });
// Or update cache directly (no refetch needed)
queryClient.setQueryData(
['products', data.category],
(old) => [...(old || []), data]
);
},
onError: (error) => {
toast.error(`Failed to create: ${error.message}`);
},
});
}
function CreateProductForm() {
const { mutate, isPending } = useCreateProduct();
function handleSubmit(e) {
e.preventDefault();
const formData = new FormData(e.target);
mutate({
name: formData.get('name'),
price: Number(formData.get('price')),
});
}
return (
<form onSubmit={handleSubmit}>
<input name="name" required />
<input name="price" type="number" required />
<button disabled={isPending}>
{isPending ? 'Creating...' : 'Create Product'}
</button>
</form>
);
}
Mutations handle writes. After success, invalidateQueries triggers a refetch of related data, or setQueryData updates the cache directly for instant UI updates.
function useToggleTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (todoId) => api.patch(`/todos/${todoId}/toggle`),
// Optimistic update: update UI before server responds
onMutate: async (todoId) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['todos'] });
// Snapshot the previous value
const previousTodos = queryClient.getQueryData(['todos']);
// Optimistically update the cache
queryClient.setQueryData(['todos'], (old) =>
old.map(todo =>
todo.id === todoId
? { ...todo, done: !todo.done }
: todo
)
);
// Return snapshot for rollback
return { previousTodos };
},
// Rollback on error
onError: (err, todoId, context) => {
queryClient.setQueryData(['todos'], context.previousTodos);
toast.error('Failed to update todo');
},
// Refetch after success or error to ensure consistency
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
}
// Usage: instant feedback
function TodoItem({ todo }) {
const { mutate: toggleTodo } = useToggleTodo();
return (
<li
onClick={() => toggleTodo(todo.id)}
style={{ textDecoration: todo.done ? 'line-through' : 'none' }}
>
{todo.text}
</li>
);
}
Optimistic updates show changes instantly. onMutate saves a snapshot and updates the cache. onError rolls back. onSettled refetches to ensure server and client are in sync.
React Query, TanStack Query, Caching, Mutations, Optimistic Updates