Chapter 14
Server State — TanStack Query (React Query)
TanStack Query (React Query) for server state in React 19.2 — cache-key-as-contract, optimistic mutations, prefetching, devtools, and pairing with Zustand.
Published 2026-05-23
🎯 Chapter Goal — After this chapter you can model server data with TanStack Query’s cache-key-as-contract pattern, write mutations that optimistically update and roll back on failure, prefetch and stream paginated lists, debug a stale cache with the devtools, and pair TanStack Query with Zustand without duplicating state.
🧭 Prerequisites — Ch 12 (server cache is one of the four kinds). Ch 2 §2.2 (Actions /
useOptimistic) — TanStack Query’s mutations compose with both.
🔹 14.1 Mental model — cache, fetcher, invalidation
TanStack Query is not a fetcher. It’s a cache with a fetcher attached.
┌─────────────────────────────────────┐
│ QueryClient (in-memory cache) │
│ │
useQuery ──▶ │ Map<QueryKey, QueryState> │
│ ['invoices'] → [data, …] │
│ ['invoice', '1042'] → [data, …] │
useMutation ──▶ │ ['invoice', '1043'] → [data, …] │
│ │
│ ▼ on stale / refocus / interval │
│ refetch via fetcher fn │
└─────────────────────────────────────┘
Three primitives:
useQuery— declare “I want data identified by this key, fetched by this function.” Returns{ data, isLoading, error, isStale, refetch }.useMutation— declare “I want to perform this side effect, and tell the cache what to invalidate after.”QueryClient— the cache itself. One instance per app, provided via<QueryClientProvider>.
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query';
const qc = new QueryClient({
defaultOptions: {
queries: { staleTime: 30_000, gcTime: 5 * 60_000, retry: 1 },
},
});
const App = () => (
<QueryClientProvider client={qc}>
<Invoices />
</QueryClientProvider>
);
const Invoices = () => {
const { data, isPending, error } = useQuery({
queryKey: ['invoices'],
queryFn: () => fetch('/api/invoices').then((r) => r.json()),
});
if (isPending) return <Spinner />;
if (error) return <ErrorBox error={error} />;
return <InvoiceList invoices={data} />;
};
Three key options worth defaulting:
staleTime: 30_000— data is “fresh” for 30 s; no background refetch in that window. Default0is too aggressive.gcTime: 5 * 60_000— keep cached data for 5 minutes after the last consumer unmounts. Helps tab-switching without refetch.retry: 1— one auto-retry on failure. Default3is too aggressive for non-idempotent endpoints.
🔹 14.2 Query keys as the contract
The cache key is the API. Two queries with the same key share the cache; two with different keys don’t.
['invoices'] // list query
['invoice', id] // single-record query
['invoice', id, 'line-items'] // related sub-resource
['invoices', { status, page, q }] // filtered/paginated list
Rules:
- Keys are arrays. Strings work but arrays are clearer.
- Use a noun + identifier shape.
['invoice', id]not['get-invoice-by-id', id]. - Include every input that affects the response.
['invoices', { status }]is different from['invoices']. - Serialise objects deterministically. TanStack Query uses stable stringify under the hood; you don’t need to sort keys yourself.
Key factories for type safety:
export const invoiceKeys = {
all: ['invoices'] as const,
lists: () => [...invoiceKeys.all, 'list'] as const,
list: (filters: Filters) => [...invoiceKeys.lists(), filters] as const,
details:() => [...invoiceKeys.all, 'detail'] as const,
detail: (id: string) => [...invoiceKeys.details(), id] as const,
};
useQuery({ queryKey: invoiceKeys.list({ status: 'paid' }), queryFn: /* … */ });
Refactoring a key shape becomes a TS error in every consumer. Worth the ceremony for any real app.
🔹 14.3 Mutations and optimistic updates
const qc = useQueryClient();
const updateInvoice = useMutation({
mutationFn: (patch: { id: string; amount: number }) =>
fetch(`/api/invoices/${patch.id}`, {
method: 'PATCH',
body: JSON.stringify(patch),
headers: { 'content-type': 'application/json' },
}).then((r) => r.json()),
onMutate: async (patch) => {
// 1. Cancel any in-flight refetch for the affected key.
await qc.cancelQueries({ queryKey: invoiceKeys.detail(patch.id) });
// 2. Snapshot the previous value for rollback.
const previous = qc.getQueryData<Invoice>(invoiceKeys.detail(patch.id));
// 3. Optimistically update the cache.
qc.setQueryData<Invoice>(invoiceKeys.detail(patch.id), (old) =>
old ? { ...old, ...patch } : old,
);
return { previous }; // ← rollback context
},
onError: (_err, patch, ctx) => {
if (ctx?.previous) {
qc.setQueryData(invoiceKeys.detail(patch.id), ctx.previous);
}
},
onSettled: (_data, _err, patch) => {
qc.invalidateQueries({ queryKey: invoiceKeys.detail(patch.id) });
},
});
The pattern (onMutate → optimistic update + snapshot; onError → restore; onSettled → invalidate) is the canonical mutation flow. Memorise it.
Pairing with React 19.2 useOptimistic (Ch 2 §2.2): use useOptimistic for UI-state-only optimism (the row turns green); use TanStack Query’s onMutate for cache-state optimism (the data reflects the new value across the app). They compose — both layers contribute.
🔹 14.4 Prefetching, infinite queries, paginated lists
Prefetch on hover — the modern “feels instant” trick:
const qc = useQueryClient();
<Link
to={`/invoices/${id}`}
onMouseEnter={() => qc.prefetchQuery({
queryKey: invoiceKeys.detail(id),
queryFn: () => fetchInvoice(id),
staleTime: 10_000,
})}
>
{invoice.name}
</Link>
By the time the user clicks (250–500 ms later), the data is cached.
Infinite queries for “load more” lists:
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: invoiceKeys.list({}),
queryFn: ({ pageParam }) => fetchInvoices({ cursor: pageParam }),
initialPageParam: null as string | null,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? null,
});
const all = data?.pages.flatMap((p) => p.items) ?? [];
Pair with @tanstack/react-virtual (Ch 32.3) for truly long lists.
Paginated lists with placeholderData:
useQuery({
queryKey: invoiceKeys.list({ page }),
queryFn: () => fetchInvoices({ page }),
placeholderData: (prev) => prev, // keep showing previous page while next loads
});
The placeholderData: (prev) => prev trick prevents the spinner flash on page change — old data stays visible until new data arrives.
🔹 14.5 Devtools and debugging cache bugs
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
<QueryClientProvider client={qc}>
<App />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
What the panel shows:
- Every query, keyed by
queryKey. - State: fresh / stale / fetching / inactive.
- Last-fetched timestamp.
- Cached data preview.
- “Refetch” button per query.
Debugging recipe — “my data is stale and I don’t know why”
- Open devtools. Find the query. Is it “stale”?
- Look at
staleTime. If it’s0(default), every refocus refetches — possibly what you want. - Check whether two components are reading the same data with different query keys. The cache thinks they’re different queries; one’s update doesn’t invalidate the other.
- Check whether your mutation’s
onSettledis invalidating the right key. A typo in the key means the cache stays.
Debugging recipe — “my mutation didn’t update the list”
The list is its own query (['invoices']); the mutation updates the detail (['invoice', id]). You need to invalidate the list too:
onSettled: () => {
qc.invalidateQueries({ queryKey: invoiceKeys.all }); // matches both list and detail
},
The queryKey: invoiceKeys.all is a prefix match — invalidates anything starting with ['invoices'].
🔹 14.6 Pairing with Zustand — don’t duplicate state
A common mistake: copying server data into Zustand “for convenience.”
// ❌ wrong — duplicates the cache, drifts immediately
const useStore = create((set) => ({
invoices: [] as Invoice[],
setInvoices: (invoices) => set({ invoices }),
}));
// In a component:
const { data } = useQuery({ queryKey: ['invoices'], queryFn: fetchInvoices });
useEffect(() => { if (data) useStore.getState().setInvoices(data); }, [data]);
Now you have two sources of truth and a manual sync. Inevitably they diverge.
The right shape:
- Server data lives in TanStack Query’s cache. Period.
- Derived selections (which invoice is currently selected by the user) live in Zustand or local state — but the selection is an ID, not the data.
const useUi = create((set) => ({
selectedInvoiceId: null as string | null,
select: (id: string) => set({ selectedInvoiceId: id }),
}));
const SelectedInvoice = () => {
const id = useUi((s) => s.selectedInvoiceId);
const { data } = useQuery({
queryKey: invoiceKeys.detail(id!),
queryFn: () => fetchInvoice(id!),
enabled: id != null,
});
return data ? <InvoicePane invoice={data} /> : null;
};
The UI store holds the selection; the query cache holds the data. Each owns one concern.
🪤 Common Pitfalls
- Copying query data into Zustand — two sources of truth.
staleTime: 0(default) — every refocus refetches; sometimes wanted, often not.- Invalidating the wrong key after a mutation — list stays stale.
- Building
queryKeyarrays inline without a factory — typo invalidations. useMutationwithoutonErrorrollback — optimistic updates that don’t undo.- Catching the query error in
try/catch—useQuerydoesn’t throw; checkerror. - Setting
enabled: !!idthen accessingdata.somethingwithout a null check —dataisundefinedwhile disabled.
✅ Recap
- TanStack Query is a cache, not a fetcher.
- Query keys are the contract. Use key factories for type safety.
- The mutation pattern is
onMutate→ optimistic + snapshot,onError→ restore,onSettled→ invalidate. - Prefetch on hover for “feels instant” navigation.
- Pair with Zustand for UI state; never copy server data into a global store.
🔗 Further Reading
- https://tanstack.com/query/latest — TanStack Query v5.x docs.
- TKDodo (Dominik Dorfmeister) — “Practical React Query” blog series; the canonical deep dive.
- Ch 5 §5.1 — RR v7 loaders (alternative pattern; sometimes complements Query).
- Ch 13 — Zustand for the UI-state half.
- Ch 32.3 — virtualisation for long lists.
In the book — not on the site
Each topic has an 🧠 Under-the-hood subsection — the algorithm, the data structures, what React DevTools surfaces, debugging recipes. Plus a 🧪 hands-on lab per chapter with a starter repo. Reserved for the book.