Frontend / APIs & data fetching / 02_tanstack_query.md

TanStack Query and server state

Updated 8 interview angles 12 min read source
On this page10
  1. The mechanism: you have a copy, not the truth
  2. staleTime is a correctness decision
  3. Keys are two contracts at once
  4. Invalidate or write
  5. Prefetching
  6. Where it breaks in production
  7. The trade not taken
  8. Against SWR and RTK Query
  9. Related
  10. Interview angle

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.

tsx
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.

text
fetch ─── fresh ───┬── stale ────┬── garbage
                   │             │
            staleTime up    gcTime up and
            (refetch on     nothing is
             focus/mount)   subscribed

Two 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.

tsx
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 once

Set 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.

ts
["orders"]                              // everything about orders
["orders", "list", { status: "paid" }]  // one filtered list
["orders", "detail", orderId]           // one order

Invalidating ["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

ts
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

tsx
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:

  • setQueryData when the server told you the new value. The mutation response is the new state, so writing it is free and exact.
  • invalidateQueries when 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

tsx
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.

tsx
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.

tsx
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.

tsx
// Module scope: created ONCE per server process.
const queryClient = new QueryClient();     // every request shares this cache

Every 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

tsx
{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

tsx
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.

tsx
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

tsx
queryKey: ["report", { generatedAt: Date.now() }]   // never a cache hit

Every 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.

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. staleTime asks whether the data is still trustworthy while it is being used; gcTime asks how long to keep it after nothing is subscribed. One is freshness, the other is memory.
  • “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 QueryClient at 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 useQuery per 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 await the 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.