TanStack Query — Cache, Queries, Mutations
Server-state caching for React. v5 syntax throughout. The 80% case lives in this file; deep dives in the reference files.
Quick Reference — When to Load What
| Working on… | Read |
|---|---|
| Designing query keys, factories, fuzzy invalidation prefixes | QUERY-KEYS.md |
Mutations, pessimistic vs optimistic, refetchType, post-write invalidation |
MUTATIONS.md |
select, error handling, useInfiniteQuery, useSuspenseQuery, devtools, stale closures |
QUERIES.md |
Critical Gotchas (Always-Inline)
These are the bugs that ship to production silently. v5 changed a lot — most of these bite people coming from React Query v4.
1. v5 Single-Object Signatures
Every hook takes one options object. Positional args are gone.
// BAD: v4 positional — TypeScript error in v5
useQuery(['todos'], fetchTodos, { staleTime: 60_000 });
useMutation(updateTodo, { onSuccess });
// GOOD: v5
useQuery({ queryKey: ['todos'], queryFn: fetchTodos, staleTime: 60_000 });
useMutation({ mutationFn: updateTodo, onSuccess });
2. pending Replaced loading; isLoading Changed Meaning
// BAD: v4 status value
if (query.status === 'loading') return <Spinner />;
// GOOD: v5
if (query.status === 'pending') return <Spinner />;
// or
if (query.isPending) return <Spinner />;
isPending = old isLoading (no data yet). New isLoading = isPending && isFetching — true only when the first fetch is in flight. Use isPending for the initial-render spinner.
3. cacheTime Renamed to gcTime
// BAD
new QueryClient({ defaultOptions: { queries: { cacheTime: 600_000 } } });
// GOOD
new QueryClient({ defaultOptions: { queries: { gcTime: 600_000 } } });
gcTime is how long unused data lingers before garbage collection (default 5min). It does not control freshness — staleTime does.
4. staleTime: 0 Default Causes Refetch Thrash
Every mount and window focus refetches. Most apps want 30s–5min.
// GOOD: set once on the client, override per-query when needed
new QueryClient({
defaultOptions: { queries: { staleTime: 60_000 } },
});
5. onSuccess / onError Removed From useQuery
// BAD: silently ignored in v5
useQuery({ queryKey: ['user'], queryFn: getUser, onSuccess: (u) => track(u) });
// GOOD: side effects belong in mutations or effects
const { data } = useQuery({ queryKey: ['user'], queryFn: getUser });
useEffect(() => { if (data) track(data); }, [data]);
Callbacks are still valid on useMutation.
6. useInfiniteQuery Requires initialPageParam
// BAD: v5 throws at runtime
useInfiniteQuery({
queryKey: ['feed'],
queryFn: ({ pageParam }) => fetchFeed(pageParam),
getNextPageParam: (last) => last.nextCursor,
});
// GOOD
useInfiniteQuery({
queryKey: ['feed'],
queryFn: ({ pageParam }) => fetchFeed(pageParam),
initialPageParam: 0,
getNextPageParam: (last) => last.nextCursor ?? undefined,
});
7. Query Keys Must Be Serializable
Arrays of primitives + plain objects only. No Date, Map, Set, class instances, or functions. Object key order does not matter — deep-equal compared.
// BAD: Date is not serializable for the cache key
useQuery({ queryKey: ['report', new Date()], queryFn: getReport });
// GOOD
useQuery({ queryKey: ['report', date.toISOString()], queryFn: getReport });
8. queryFn Must Throw on Error
The cache treats a resolved promise as success — returning an error object hides failures.
// BAD: query stays in 'success' with bogus data
queryFn: async () => {
const res = await fetch('/api/todos');
return res.json(); // 4xx/5xx still resolves
};
// GOOD
queryFn: async ({ signal }) => {
const res = await fetch('/api/todos', { signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
};
Always thread signal to fetch so the cache can cancel in-flight requests.
9. enabled: false Is NOT "Idle"
A disabled query sits in status: 'pending' forever with no data. Don't gate spinners on isPending alone.
// BAD: spinner shown forever when userId is undefined
const q = useQuery({ queryKey: ['user', userId], queryFn: () => getUser(userId!), enabled: !!userId });
if (q.isPending) return <Spinner />;
// GOOD: check the gate explicitly, or use fetchStatus
if (!userId) return <SignInPrompt />;
if (q.isPending) return <Spinner />;
fetchStatus === 'idle' + isPending distinguishes disabled from in-flight.
10. placeholderData: keepPreviousData Replaced keepPreviousData: true
import { keepPreviousData } from '@tanstack/react-query';
// BAD: v4 syntax — ignored in v5
useQuery({ queryKey: ['todos', page], queryFn, keepPreviousData: true });
// GOOD
useQuery({ queryKey: ['todos', page], queryFn, placeholderData: keepPreviousData });
11. Fuzzy Invalidation Matches Prefixes
invalidateQueries({ queryKey: ['todos'] }) busts every key starting with ['todos', ...]. Usually what you want — pair with exact: true when you don't.
qc.invalidateQueries({ queryKey: ['todos'] }); // all todo queries
qc.invalidateQueries({ queryKey: ['todos', id], exact: true }); // only this one
When to Use What
| Decision | Use |
|---|---|
| Read data in a component | useQuery |
Read data and integrate with <Suspense> |
useSuspenseQuery (no enabled, no isPending) |
| Parallel reads, variable count | useQueries({ queries: [...] }) |
| Paginated/cursor lists | useInfiniteQuery with initialPageParam |
| Write data | useMutation |
| Refresh after a write — server is source of truth | queryClient.invalidateQueries |
| Refresh after a write — mutation returns fresh entity | queryClient.setQueryData (skip the refetch) |
| Common pattern after a write | setQueryData(detail) + invalidateQueries(list._def) |
| Evict on logout | queryClient.removeQueries (or queryClient.clear()) |
| Disable until args ready | enabled: false on useQuery (never on useSuspenseQuery) |
| Stable spinner across page changes | placeholderData: keepPreviousData |
| SSR | prefetchQuery + dehydrate + <HydrationBoundary>, new QueryClient per request |
| Devtools | @tanstack/react-query-devtools, import behind a dev check |
Rules
- v5 only: single-object signatures,
pendingnotloading,gcTimenotcacheTime,isPendingfor the first-fetch spinner,placeholderData: keepPreviousDataover the old flag. - Set
staleTimeon the client. The default of 0 thrashes; pick a project-wide floor (30s–5min) and override per-hook only when needed. - Query keys are serializable arrays, broad to narrow:
['todos', 'list', { filters }],['todos', 'detail', id]. Stringify dates; never put functions,Map/Set, or class instances in a key. queryFnthrows on non-OK responses. Resolved promise = success in the cache. Always threadsignalfor cancellation.- No side effects in
useQuery.onSuccess/onErrorare gone — put effects inuseEffector in the relevantuseMutation. - Default to pessimistic mutations. Wait for the server, then
setQueryDataorinvalidateQueries. Reach for optimistic only when latency hurts UX — and then all five steps:cancelQueries→ snapshot →setQueryData→onErrorrollback →onSettledinvalidate. See MUTATIONS.md. - One factory per domain, then
mergeQueryKeys. Do not build a single 1000-line monolith. See QUERY-KEYS.md. enabled: falseis not idle. Gate UI on the precondition itself, not onisPending. Never combineenabledwithuseSuspenseQuery.- SSR: create a fresh
QueryClientper request on the server, and on the client create it insideuseState(() => new QueryClient())— never at module scope. - Devtools: import
@tanstack/react-query-devtoolsonly in development (dynamic import orNODE_ENVguard) to keep the prod bundle clean.
Reference Files
For deeper guidance, load the file matching what you're working on:
- QUERY-KEYS.md — read before your first
useQueryin a repo — it may already have a key factory you must extend — and when designing query keys, building a@lukemorales/query-key-factoryfactory, or invalidating cache by prefix. Covers the merge pattern (onecreateQueryKeysfile per domain, composed viamergeQueryKeys), the_def/_ctx/contextQueriesshapes, fuzzy vs exact invalidation targets, andinferQueryKeysfor type extraction. - MUTATIONS.md — read when writing
useMutation, doing optimistic updates, or invalidating after a write. Covers themutatevsmutateAsyncchoice, theonMutate → mutationFn → onSuccess/onError → onSettledlifecycle, pessimistic vs optimistic — pick the default, the canonicalcancelQueries → snapshot → setQueryData → rollback → invalidatetemplate, the fullrefetchTypetable (including'none'for broad invalidations), call-site vs hook-level callbacks, and concurrent-mutationscope. - QUERIES.md — read when reading data, tuning options, handling errors, or integrating devtools. Covers
selectfor derived state and re-render stability, error handling (throwOnError, retry, retryDelay, error boundaries), fulluseInfiniteQueryexample + key-change reset gotcha,useSuspenseQueryanduseQueriesexamples, stale closures inqueryFn, network/focus options (networkMode, dev-moderefetchOnWindowFocus,gcTime: 0), and Devtools setup.