TanStack Query
Reference for TanStack Query v5 (@tanstack/react-query), the async state
manager for server data in React. Prefer the project's existing API client,
query-key conventions, and hook layer; apply these rules on top so cache
identity, staleness, and invalidation stay predictable.
TanStack Query owns server state: data it did not create, stored elsewhere, potentially stale at any moment. It is not a replacement for client state (form inputs, modal toggles, wizard steps) — keep those in React state.
Branch-specific references, loaded on demand:
- ssr.md — prefetch,
dehydrate,HydrationBoundary, Next.js App Router and Server Components. - mutations.md —
useMutationlifecycle, optimistic updates, rollback, invalidation strategy. - infinite-queries.md —
useInfiniteQuery, cursors, bi-directional lists, page-limited caches. - typescript.md — inference, narrowing, global
Registertypes,skipToken.
First Checks
- Confirm
@tanstack/react-queryis installed and on v5 (package.json, lockfile). v4 code differs on renamed options —cacheTimebecamegcTime,useErrorBoundarybecamethrowOnError,keepPreviousDatabecameplaceholderData: keepPreviousData. - Find where the
QueryClientis created and whichdefaultOptionsare set —staleTime,gcTime,retrychange the behavior of every hook below. - Find the existing query-key convention (flat arrays, key factories, or
queryOptionsfunctions) and reuse it instead of inventing a new one. - Find the existing fetch layer: query functions must throw on failure, and
fetchdoes not throw on a 4xx/5xx by itself. - If the app server-renders, identify the prefetch boundary before writing any hook — see ssr.md.
Keys: The Key Is The Cache Identity
A query key is an array, serializable with JSON.stringify, and unique to
the data it describes. Every variable the query function reads and that
changes the response belongs in the key. Keys act as dependencies: when the
key changes, the query is a different cache entry and refetches.
| Case | Pattern | Why |
|---|---|---|
| Generic list | queryKey: ['todos'] |
Constant key for a non-hierarchical resource. |
| Item by id | queryKey: ['todo', 5] |
Primitive identifies the item. |
| Extra parameters | queryKey: ['todos', { status, page }] |
Object holds the parameters that shape the response. |
| Scope | queryKey: ['todos', tenantId, { status }] |
Scope-changing inputs are part of the identity. |
Hashing rules that decide whether two keys are the same entry:
- Object keys are hashed deterministically:
{ status, page }and{ page, status }are the same key, and a property set toundefinedis ignored. - Array item order matters:
['todos', status, page]and['todos', page, status]are two different entries. - Prefix matching drives invalidation:
['todos']matches['todos', { page: 1 }]unlessexact: trueis passed. Order keys from generic to specific so the prefix you want to invalidate is the leftmost segment.
Colocate With queryOptions
queryOptions returns its input unchanged at runtime, but it ties queryKey
and queryFn together and carries the result type into every consumer. Prefer
it over loose key constants as soon as a query is used in more than one place.
import { queryOptions } from '@tanstack/react-query'
export function todoOptions(todoId: string) {
return queryOptions({
queryKey: ['todos', todoId],
queryFn: () => fetchTodoById(todoId),
staleTime: 5 * 60 * 1000,
})
}
useQuery(todoOptions('5'))
useSuspenseQuery(todoOptions('5'))
queryClient.prefetchQuery(todoOptions('5'))
queryClient.setQueryData(todoOptions('5').queryKey, nextTodo)
useQueries({ queries: [todoOptions('1'), todoOptions('2')] })runs them in parallel from the same definition.- Override per component with a spread:
useQuery({ ...todoOptions(id), select }). infiniteQueryOptionsis the equivalent helper for infinite queries;mutationOptionsfor mutations.queryClient.getQueryData(todoOptions(id).queryKey)is typed thanks to the helper — without it the result isunknown(typescript.md).
Query Functions
A query function returns a promise that resolves the data or throws.
useQuery({
queryKey: ['todos', todoId],
queryFn: async ({ signal }) => {
const response = await fetch(`/api/todos/${todoId}`, { signal })
if (!response.ok) {
throw new Error('Network response was not ok')
}
return response.json()
},
})
- Resolving
undefinedis treated as a failed query. Resolvenullto store "nothing" as a success. fetchdoes not throw on HTTP error statuses — throw explicitly.- The function receives a
QueryFunctionContext:queryKey,client,signal(pass it through for cancellation), andmeta.
Defaults That Decide Behavior
v5 defaults, worth knowing because they explain most surprises:
| Option | Default | Effect |
|---|---|---|
staleTime |
0 |
Cached data is stale immediately, so it refetches on mount, window focus, and reconnect. |
gcTime |
5 * 60 * 1000 |
Inactive entries (no mounted observer) are garbage collected after 5 minutes. |
retry |
3 |
Failures retry three times with exponential backoff before surfacing an error. |
structuralSharing |
true |
Unchanged parts of the response keep their reference, so consumers do not re-render. |
mutation retry |
0 |
Mutations do not retry by default. |
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
retry: (failureCount, error) =>
error instanceof HttpError && error.status < 500 ? false : failureCount < 3,
},
},
})
staleTimeis the main lever against excessive refetching; tune it per endpoint (viaqueryOptions) rather than disablingrefetchOnWindowFocusglobally.staleTime: Infinitystops staleness-based refetching but still responds toinvalidateQueries.staleTime: 'static'also ignores manual invalidation andrefetchOn*: 'always'— reserve it for data that cannot change while the app runs (boot-time feature flags, permissions loaded at login).gcTimeis a cache-retention timer, not a freshness timer; it only starts once a query has no observers.- Never retry blindly on 4xx: a
retrypredicate that inspects the status avoids three doomed round-trips on every 404.
Reading A Query
const { data, error, status, isPending, isFetching, isError } = useQuery(
todoOptions(todoId),
)
status: 'pending' | 'error' | 'success'describes the data;fetchStatus: 'fetching' | 'paused' | 'idle'describes the request. A query with cached data that is refetching issuccess+fetching.isPendingmeans "no data yet". UseisFetchingfor background-refresh indicators over already-rendered data.- For disabled or lazy queries use
isLoading(isPending && isFetching) — a disabled query ispendingforever and would pin a spinner on screen. selectsubscribes the component to a slice of the data and re-runs only whendataor the function reference changes; extract it to a stable reference or wrap it inuseCallbackinstead of inlining.- Re-renders are tracked per accessed property via a Proxy. Object rest
destructuring (
const { data, ...rest }) touches every property and disables that optimization.
Disabling And Dependent Queries
Call hooks unconditionally; put the condition in enabled or in the query
function.
const { data: user } = useQuery({
queryKey: ['user', email],
queryFn: () => getUserByEmail(email),
})
const { data: projects } = useQuery({
queryKey: ['projects', user?.id],
queryFn: () => getProjectsByUser(user!.id),
enabled: !!user?.id,
})
- A disabled query ignores
invalidateQueriesandrefetchQueries, does not fetch on mount, and does not refetch in the background. skipTokenas thequeryFnis the type-safe alternative toenabled: falseand removes the non-null assertion — butrefetch()then fails withMissing queryFn. Useenabled: falsewhen manualrefetch()is required.- Dependent queries are a client-side waterfall. When both are needed for the first paint, prefetch them on the server (ssr.md).
Suspense
const { data } = useSuspenseQuery(todoOptions(todoId))
datais guaranteed defined, so nostatushandling is needed; loading goes to<Suspense>and errors to an error boundary.- The trade-off: no
enabled, noplaceholderData, and queries inside one component fetch in serial. UseuseSuspenseQueriesto parallelize. - Errors are only thrown to the boundary when there is no data to show
(
throwOnErrordefaults to(error, query) => typeof query.state.data === 'undefined'). Throw manually if every error must reach the boundary. - Reset errors on retry with
QueryErrorResetBoundaryoruseQueryErrorResetBoundary, wired to the error boundary'sonReset. - Wrap key changes in
startTransitionso the fallback does not replace the rendered UI on every update. - With SSR, only use
useSuspenseQueryfor queries that are always prefetched — a forgotten prefetch produces a hydration mismatch.
Mutations And Invalidation
const queryClient = useQueryClient()
const { mutate, isPending } = useMutation({
mutationFn: addTodo,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
})
invalidateQueriesmarks matching queries stale — overriding theirstaleTime— and refetches the ones currently rendered.- Match by prefix by default; add
exact: trueto hit a single entry, orpredicatefor anything finer. - Return the promise from
onSuccess/onSettledto keep the mutationisPendinguntil the refetch lands. mutateis fire-and-forget with callbacks;mutateAsyncreturns a promise you must catch yourself.
Optimistic updates, rollback, useMutationState, and offline behavior are in
mutations.md.
Pagination
Put the page in the key and keep the previous page on screen while the next one loads:
import { keepPreviousData, useQuery } from '@tanstack/react-query'
const { data, isPlaceholderData } = useQuery({
queryKey: ['projects', page],
queryFn: () => fetchProjects(page),
placeholderData: keepPreviousData,
})
Without it the UI flips between pending and success on every page change.
Guard the "next" control with isPlaceholderData so a page is not skipped.
placeholderData is never written to the cache; initialData is.
For "load more" and infinite scroll, see infinite-queries.md.
Review Checklist
- Every query key is an array that contains all variables shaping the response, ordered generic-to-specific so prefix invalidation works.
- Shared queries go through a
queryOptionsfunction rather than duplicated key/fn pairs. - Query functions throw on HTTP errors and never resolve
undefined. staleTimeis set deliberately per endpoint;retrydoes not retry client-error responses.- Loading UI uses
isPending/isLoadingcorrectly and distinguishes background refetches viaisFetching. - Conditional fetching uses
enabledorskipToken, never a conditional hook call. - Suspense queries are always prefetched on server-rendered routes, and error boundaries can be reset.
- Mutations invalidate or update every affected key, and optimistic updates cancel in-flight queries, snapshot, and roll back (mutations.md).
- Server rendering creates one
QueryClientper request and hydrates throughHydrationBoundary(ssr.md). - Infinite queries define
initialPageParamand returnundefinedfromgetNextPageParamat the end of the list (infinite-queries.md).