React TanStack Query Tutorial
Master TanStack Query for React data fetching with caching, background refetching, mutations, and invalidation — the modern replacement for ad-hoc useEffect fetching.
What you'll learn
- ✓Why server state is different
- ✓Queries, mutations, and keys
- ✓Stale time vs cache time
- ✓Invalidation patterns
- ✓Common mistakes that break caching
Prerequisites
- •Familiar with React hooks and async/await
TanStack Query, formerly React Query, is the de facto library for managing server state in React. It handles caching, retries, background refetches, and invalidation so you stop writing the same useEffect fetch over and over. This tutorial covers the model and the patterns.
What and Why
There are two kinds of state in a React app. Client state is local UI: toggles, form drafts, modals. Server state is data owned by a backend that you cache locally: users, orders, posts. They look similar but behave differently. Server state is stale by default, shared across components, and lives somewhere you do not control.
TanStack Query treats server state as a cache, not as state you own. You declare what you want, it manages when to fetch, refetch, retry, and garbage-collect. The mental shift from useEffect(fetch) to useQuery is the biggest productivity gain in modern React.
Mental Model
Every piece of server data has a key. The key is your cache identifier. When two components ask for the same key, they share one fetch and one result. When the data is stale, the next access triggers a background refetch while showing the cached value, so users never see a blank screen.
Mutations are how you change data. After a mutation succeeds, you invalidate the keys that depend on it. Invalidation marks them stale, and the next access refetches.
Hands-on Example
Here is a list and a mutation that adds an item.
useQuery(['todos'])
|
v
[cache hit?] -- yes --> show cached, refetch in background
| no
v
fetch -> cache -> render
mutation success
|
v
queryClient.invalidateQueries(['todos'])
|
v
next access -> refetch -> update UI import { QueryClient, QueryClientProvider, useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
const queryClient = new QueryClient();
function App() {
return (
<QueryClientProvider client={queryClient}>
<Todos />
</QueryClientProvider>
);
}
function Todos() {
const qc = useQueryClient();
const { data, isLoading, error } = useQuery({
queryKey: ['todos'],
queryFn: () => fetch('/api/todos').then((r) => r.json()),
staleTime: 30_000,
});
const addTodo = useMutation({
mutationFn: (title) =>
fetch('/api/todos', { method: 'POST', body: JSON.stringify({ title }) }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['todos'] }),
});
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error</p>;
return (
<>
<button onClick={() => addTodo.mutate('New task')}>Add</button>
<ul>{data.map((t) => <li key={t.id}>{t.title}</li>)}</ul>
</>
);
}
staleTime: 30_000 says the data is fresh for thirty seconds. During that window, navigating between components does not trigger a refetch. After expiry, the next mount refetches in the background.
Common Pitfalls
Setting staleTime to zero everywhere is the most common waste. Every focus triggers a refetch, every mount triggers a refetch, and you lose the cache benefit. Pick a sensible default in QueryClient defaults, then override per query.
Unstable query keys destroy the cache. A key like ['user', { id, includeOrders: true }] works because objects are serialized deterministically. A key that includes a new array literal each render breaks identity.
Forgetting to invalidate after mutations is the second most common bug. The UI looks fine because the mutation succeeded, but the cache is stale and the next page navigation shows old data.
Putting client-only state in TanStack Query is a category error. A modal open flag belongs in useState, not a query.
Best Practices
Centralize query keys in a constants file or a factory so renames stay consistent. Many teams use a keys.ts with todos.all, todos.detail(id), and friends.
Use select to narrow data without extra queries. If a component only needs a count, select the count, not the array. Re-renders shrink without changing the cache.
For mutations that update UI immediately, use onMutate for optimistic updates and roll back in onError. The library has first-class support for this.
Pair with the devtools. Watching the cache live makes the model click in minutes.
Wrap-up
TanStack Query is one of those libraries that changes how you think about data. Once server state is a cache, the rest of your code gets simpler. Start with a single query, set sensible defaults, and let the library handle the boring parts.
Related articles
- React React SuspenseList Tutorial
Coordinate multiple Suspense boundaries with SuspenseList to control reveal order and loading states, reducing layout shift and chaos during streamed data fetching.
- React React Context vs Redux: When to Use Which
A practical comparison of React Context and Redux: rendering model, performance, devtools, and concrete heuristics for picking the right tool.
- React React Error Boundaries: A Practical Guide
How to build resilient React apps with Error Boundaries: what they catch, what they miss, and how to design fallback UI that actually helps users.
- React React Forms: Controlled vs Uncontrolled Inputs Explained
Understand the difference between controlled and uncontrolled inputs in React, when to use each, and how to combine them with refs and form libraries.