TanStack Query and server state
Article.
Teams put API responses in Redux, write three actions and a reducer per endpoint, and call it state management. Then someone introduces TanStack Query and most of that code deletes itself.
The reason is not that the library is clever. It is that server state is a different kind of state, and managing it with client-state tools means hand-building a cache without admitting that is what you are doing.
The mechanism: you have a copy, not the truth
| Client state | Server state | |
|---|---|---|
| Who owns it | you | someone else |
| Can go stale | no | yes, silently |
| Access | synchronous | asynchronous |
| Shared | within your app | across users |
| On error | cannot fail | must handle |
A form’s open/closed flag is client state — you own it, it is never wrong. A list of orders is server state — the database owns it, your copy was correct when it arrived and may be wrong now, and another user can change it without telling you.
Every problem people solve with Redux for API data is a caching problem: deduplication, invalidation, staleness, refetch, garbage collection. Once that is named, the tool follows.
const { data, isPending, error } = useQuery({
queryKey: ["user", userId],
queryFn: () => fetchUser(userId),
staleTime: 60_000,
});The library then gives you deduplication across components, a cache keyed by that array, a refetch policy, and a status machine — none of which you write.
staleTime is a correctness decision
The knob people set carelessly, and the one worth thinking hardest about.
staleTime is your answer to “how wrong am I willing to be?” It is not a
performance setting.
fetch ─── fresh ───┬── stale ────┬── garbage
│ │
staleTime up gcTime up and
(refetch on nothing is
focus/mount) subscribedTwo clocks measuring different things, and confusing them is the classic misunderstanding:
staleTime |
gcTime |
|
|---|---|---|
| Question | is this data still trustworthy? | is anyone still using it? |
| Default | 0 — always stale | 5 minutes |
| Runs while | data is cached | nothing is subscribed |
The default of 0 means every mount, every window focus and every reconnect
triggers a refetch. That is a deliberate “always be correct” default, and it is
frequently wrong for data that changes hourly.
staleTime: 0 // a live price, a chat thread
staleTime: 60_000 // a list a colleague might edit
staleTime: 60 * 60_000 // a country list, a permission set
staleTime: Infinity // truly immutable, fetched onceSet it per resource, from how fast the underlying thing actually changes. One global default across an app means every resource is refetched at the rate appropriate for the most volatile one.
Keys are two contracts at once
The query key determines deduplication — same key, one request — and invalidation, because invalidation matches by prefix.
["orders"] // everything about orders
["orders", "list", { status: "paid" }] // one filtered list
["orders", "detail", orderId] // one orderInvalidating ["orders"] marks all three stale. Invalidating
["orders", "detail", id] touches only that one. Structure the key
hierarchically, narrowing left to right, or you cannot invalidate at the
granularity you need.
Keys are hashed deterministically with object keys sorted, so
{ status, page } and { page, status } are the same key. What is not stable
is a value that changes every render.
Put the keys in one place
export const orderKeys = {
all: ["orders"] as const,
lists: () => [...orderKeys.all, "list"] as const,
list: (f: Filters) => [...orderKeys.lists(), f] as const,
details: () => [...orderKeys.all, "detail"] as const,
detail: (id: string) => [...orderKeys.details(), id] as const,
};Hand-written key arrays drift: one file says ["orders", id], another says
["order", id], and the invalidation after a mutation silently matches
nothing — no error, just a list that does not update. A key factory makes that
a type error instead of a bug report.
Invalidate or write
const qc = useQueryClient();
const mutation = useMutation({
mutationFn: createOrder,
onSuccess: (created) => {
qc.setQueryData(orderKeys.detail(created.id), created); // authoritative
qc.invalidateQueries({ queryKey: orderKeys.lists() }); // guessing
},
});The rule that keeps this correct:
setQueryDatawhen the server told you the new value. The mutation response is the new state, so writing it is free and exact.invalidateQuerieswhen you would be guessing. After creating an order, you do not know where it sorts in a filtered, paginated list, what the total count is, or whether a server-side rule changed something else.
Writing a guess into the cache produces a UI that disagrees with the server until something else happens to refetch, and those bugs are very hard to reproduce. Optimistic updates are the deliberate version of that trade — see Optimistic Updates and Rollback.
Prefetching
function Row({ orderId }: { orderId: string }) {
const qc = useQueryClient();
return (
<tr
onMouseEnter={() =>
qc.prefetchQuery({
queryKey: orderKeys.detail(orderId),
queryFn: () => fetchOrder(orderId),
staleTime: 30_000,
})
}
>…</tr>
);
}Hover is roughly 200-300ms of free time before a click, which is often the whole
request. The staleTime matters here: without one, the prefetched data is stale
on arrival and the destination refetches anyway, so the prefetch bought nothing.
Where it breaks in production
The cache survives logout
The security bug in this library, and it is not obvious.
async function logout() {
await api.logout();
router.push("/login");
// The next user to log in on this machine sees the previous
// user's cached orders until each query refetches.
}The cache is in memory, keyed by query, and it knows nothing about who is signed in. On a shared machine that is a real disclosure.
async function logout() {
await api.logout();
qc.clear(); // drop everything
router.push("/login");
}The alternative, for apps that switch identity often, is to put the user id in the keys so a different user simply cannot hit the same entries.
One QueryClient shared across requests
The server-side version of the same bug, and worse because it crosses users on the server.
// Module scope: created ONCE per server process.
const queryClient = new QueryClient(); // every request shares this cacheEvery request now reads and writes one cache, so one user’s prefetched data is served to the next. Create the client per request — inside the component, or memoized per request by the framework — and only reuse a single instance in the browser, where there is exactly one user.
The list that fires fifty requests
{orders.map(o => <OrderRow key={o.id} id={o.id} />)}
// each row: useQuery(orderKeys.detail(o.id))Deduplication does not help — the keys are all different. Fifty rows means fifty requests, and the browser’s per-origin connection limit turns it into a queue.
Return enough in the list endpoint to render a row, or batch the detail fetch, or virtualize so only visible rows query. This is the N+1 problem, moved to the browser.
A dependent-query waterfall
const { data: user } = useQuery({ queryKey: ["me"], queryFn: fetchMe });
const { data: org } = useQuery({
queryKey: ["org", user?.orgId],
queryFn: () => fetchOrg(user!.orgId),
enabled: !!user, // waits for the first
});enabled is correct here and it is strictly sequential. Two round trips before
anything renders, and a third if org gates something else.
Sometimes genuinely necessary. Often the fix is an endpoint that returns both, which is the argument that data shape should follow the view rather than the table layout.
Refetch storms on focus
With staleTime: 0, alt-tabbing back to the app refetches every mounted
query at once. On a dashboard with twenty widgets that is twenty simultaneous
requests, every time the user switches windows.
Raise staleTime per resource, and turn refetchOnWindowFocus off for the ones
that genuinely do not need it. The default exists to be correct, not to be
cheap.
Pagination that flashes empty
Moving to page 2 unmounts page 1’s data, so the table blanks and the layout jumps — bad CLS and worse UX.
import { keepPreviousData } from "@tanstack/react-query";
useQuery({
queryKey: orderKeys.list(filters),
queryFn: () => fetchOrders(filters),
placeholderData: keepPreviousData, // keep the old page visible
});isFetching then tells you to show a subtle loading indicator rather than a
skeleton. See
Pagination — Offset vs Cursor, and Infinite Queries.
A key that changes every render
queryKey: ["report", { generatedAt: Date.now() }] // never a cache hitEvery render is a new key, so every render is a miss, a fetch, and a new cache entry that will never be read again. The symptom is a request loop and memory growth. Keys must be derived from stable inputs.
isLoading in v5
In v5 the status values are pending, error, success, and isPending is
the flag for “no data yet”. isLoading still exists but is derived — it means
isPending && isFetching, so it is false for a pending query that is not
currently in flight.
The practical rule: isPending for “do I have data”, isFetching for “is
something in flight”, and use the second for background indicators rather than
full skeletons.
The trade not taken
Two honest limits.
Server Components remove the need for this on initial load. If a server
component can await the data and render it, there is no loading state, no
cache key and no round trip after hydration — the library’s remaining job is
mutations and revalidation inside client components. On a data-heavy route that
is a large part of what you were using it for. See
React Server Components.
Do not put client state in it. A QueryClient holding form drafts, modal
flags or a wizard step is the original mistake running in the other direction:
that state is never stale, has no server to refetch from, and gains nothing from
a cache. useState, a context, or a small store is the right home, and keeping
the two kinds of state in different places is most of the value of the
distinction.
The library is worth it when data is fetched in more than one place, changes
outside your app, or has to stay coherent across components. For a page that
fetches one thing once, use() with Suspense or a plain fetch is less
machinery and equally correct.
Against SWR and RTK Query
Interviewers pair “React Query or SWR” as if it were a preference. It is a scope decision: all three solve the same server-state problem with the same stale-while-revalidate model, and they differ in how much they take on.
| TanStack Query | SWR | RTK Query | |
|---|---|---|---|
| Owner | community | Vercel | Redux Toolkit |
| Mutations | first-class lifecycle | mutate and roll your own |
generated hooks |
| Scope | infinite, prefetch, devtools | small surface | the Redux store |
| Frameworks | React, Vue, Solid, Svelte | React | React with Redux |
SWR is the smaller commitment. A hook, a key, a fetcher; global mutate for invalidation. It is a fine default in a Next.js codebase where the server already does the heavy lifting, and its API fits on one page. What you give up is the mutation lifecycle — optimistic updates, rollback and per-mutation callbacks are yours to assemble — plus infinite queries, prefetching and the devtools that make cache behaviour visible when it goes wrong.
TanStack Query is the larger commitment, and the one to reach for when mutations are a real part of the app rather than an occasional form: it has an opinion about optimistic updates, invalidation and retry that you would otherwise write yourself. See Optimistic Updates and Rollback.
RTK Query only makes sense if you already run Redux Toolkit. Its cache lives in the store and its hooks are generated from an API definition, so it integrates with what you have and is the wrong choice as a standalone pick.
The answer that lands: name the shared model, then choose by how much mutation machinery you need. Saying “SWR is worse” is the tell that you have used one.
Related
Interview angle 8
- “Why use TanStack Query instead of Redux for API data?” — because server
state is a different kind of state: you hold a copy of something someone else
owns, it can go stale without you acting, and it is asynchronous. Every piece
of Redux boilerplate around an endpoint is a hand-built cache — dedup,
invalidation, refetch — so use a cache.
- Follow-up: “So what is left for Redux or Zustand?” — actual client state: form drafts, modal flags, wizard steps. Never stale, no server to refetch from, nothing to invalidate. Keeping the two apart is most of the benefit.
- “How do you choose
staleTime?” — from how fast the underlying data really changes, because it is a statement about how wrong you are willing to be. The default of 0 refetches on every mount, focus and reconnect, which is right for a live price and wasteful for a country list.- Follow-up: “How is that different from
gcTime?” — different clocks.staleTimeasks whether the data is still trustworthy while it is being used;gcTimeasks how long to keep it after nothing is subscribed. One is freshness, the other is memory.
- Follow-up: “How is that different from
- “How do you structure query keys?” — hierarchically, narrowing left to
right, because invalidation matches by prefix.
["orders"],["orders","list",filters],["orders","detail",id]lets you invalidate at the granularity you need. Put them in a key factory so a typo is a type error rather than an invalidation that silently matches nothing. - “After a mutation, invalidate or
setQueryData?” — write when the server told you the new value, since the mutation response is authoritative. Invalidate when you would be guessing — where a new row sorts in a filtered list, what the total is, what else a server rule changed.
Under pressure
- “A user logs out and a second user logs in on the same machine.” — the
cache is in memory and knows nothing about identity, so the second user sees
the first user’s data until each query refetches. Call
queryClient.clear()on logout, or scope keys by user id. - “You created the
QueryClientat module scope for SSR.” — then every request on that server process shares one cache and users see each other’s data. The client must be per request on the server, and a single shared instance only in the browser. - “Your table fires fifty requests.” — one
useQueryper row, all with different keys, so deduplication cannot help. Return enough in the list endpoint, batch, or virtualize. It is N+1 relocated to the browser. - “When would you not reach for it?” — when a server component can just
awaitthe data, or when a page fetches one thing once. It earns its place when data is fetched in several places, changes outside your app, or has to stay coherent across components.