TanStack Query — Cache Keys, Invalidation, Prefetch
TL;DR
TanStack Query (React Query) is a server-state cache — not a state manager. You give it a key + a fetcher, it gives you back data, loading/error state, and a deduplicated, automatically-refetched, cache-coherent value across components. The two things to get right: cache keys (your dedup contract) and invalidation (your freshness contract). Most “Redux” code disappears once server state lives here. SWR is a smaller, simpler alternative; RTK Query is Redux-native equivalent; the patterns below apply to all three.
Interview Q&A
Q: What problem does TanStack Query solve that local state doesn’t?
A: Three problems at once:
- Dedup — ten components requesting the same
useQuery(["user", id])produce one network call. - Cache + revalidation — the response is cached by key, and refetched per a policy (on focus, on reconnect, on mount, after staleTime).
- Loading/error/data state machine —
isLoading,isFetching,error,data,isStale— no manual reducer.
Plus invalidation: after a mutation, you mark keys stale and connected components refetch automatically.
const { data, isLoading, error } = useQuery({
queryKey: ["user", userId],
queryFn: () => fetchUser(userId),
staleTime: 60_000, // fresh for 60s — no refetch on remount within window
});
Q: Anatomy of a cache key — what goes in it?
A: Everything that varies the response. The key is your dedup contract.
// Good — list-scoped key including filters
["orders", { status: "paid", page: 2 }]
// Good — entity by id
["user", userId]
// Bad — same key for filtered and unfiltered list = collision
["orders"]
Convention: an array starting with a domain noun, narrowing left-to-right (["orders"] → ["orders", "list", filters] → ["orders", "detail", id]). The library hashes deterministically (sorted keys, ignored function refs) — { status: "paid", page: 2 } and { page: 2, status: "paid" } are the same key.
Q: staleTime vs gcTime (formerly cacheTime) — what’s the difference?
A:
staleTime |
gcTime |
|
|---|---|---|
| What it controls | when the data is considered stale and eligible for background refetch | how long unused cache entries stay in memory before GC |
| Default | 0 (always stale) |
5 minutes |
| When it matters | refetch-on-focus / -mount / -reconnect behavior | when components unmount and remount |
A common production setting for “data that doesn’t change often” is staleTime: 5 * 60_000; for “real-time-ish data” leave it at 0 and rely on refetch policies.
Q: How do you invalidate after a mutation?
A:
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: createOrder,
onSuccess: () => {
// mark all "orders" queries stale → automatic refetch of mounted ones
queryClient.invalidateQueries({ queryKey: ["orders"] });
},
});
Key-prefix invalidation: ["orders"] invalidates any key starting with "orders" (["orders", "list", ...], ["orders", "detail", id]). That’s why hierarchical keys matter.
Alternative: surgical update with setQueryData to write directly into the cache without a refetch (cheaper, riskier — see 03_optimistic_updates.md).
Q: What’s prefetchQuery for?
A: Warming the cache before a user navigates so the destination is instant.
// on hover of a row, prefetch the detail
function Row({ orderId }: { orderId: string }) {
const queryClient = useQueryClient();
const onMouseEnter = () => {
queryClient.prefetchQuery({
queryKey: ["orders", "detail", orderId],
queryFn: () => fetchOrder(orderId),
staleTime: 30_000,
});
};
return <tr onMouseEnter={onMouseEnter}>…</tr>;
}
In Next.js App Router you prefetch on the server and dehydrate into the client — Next’s HydrationBoundary is the pattern.
Q: How do you avoid request waterfalls in a list-detail view?
A: Either:
- Parallel queries — fire detail + summary in parallel from the same effect.
- Prefetch + render — the parent prefetches detail keys before rendering children.
- Query inclusion in the list endpoint — the API returns enough for the row + the detail is lazy.
The bug to avoid: rendering a list, each row mounts a useQuery for its details, browser opens 50 parallel requests. Prefer batching API or virtualization with on-demand fetch.
Q: What’s useQueries for?
A: A dynamic list of queries (one per id) — when the count is data-driven you can’t call useQuery in a loop.
const results = useQueries({
queries: orderIds.map((id) => ({
queryKey: ["orders", "detail", id],
queryFn: () => fetchOrder(id),
})),
});
Each entry has its own data/isLoading. Useful for dashboards and batched details.
Q: How do you type a TanStack Query call?
A: Generics for TData/TError/TQueryKey. Usually you just type the fetcher:
const { data } = useQuery({
queryKey: ["user", userId] as const,
queryFn: async (): Promise<User> => {
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
},
});
// data: User | undefined
select lets you transform the cached value per-component without re-fetching:
const { data: name } = useQuery({
queryKey: ["user", userId],
queryFn: () => fetchUser(userId),
select: (u) => u.name,
});
Q: How does TanStack Query interact with Server Components / SSR?
A: On the server, you prefetchQuery into a QueryClient, then dehydrate and pass to the client; the client HydrationBoundary reads the dehydrated state and the first useQuery resolves instantly without a network call. This pattern works in Next App Router, Remix, and bare React 18 SSR.
Gotchas / edge cases
- Unstable query keys —
["user", { ts: Date.now() }]re-keys every render and cache-misses every time. Keys must be stable across renders. - Object inside the queryFn closure — capturing a fresh object/function in each render is fine; the key is what determines dedup.
enabled: false— query won’t run until enabled. Use to gate on auth or dependent data:enabled: !!userId.- Background refetch surprises users with stale data flashing.
keepPreviousData: true(orplaceholderData: previous) keeps the previous page visible during paginated refetches. - Mutations don’t auto-invalidate — you must call
invalidateQueries(or update withsetQueryData). - Garbage collection on unmount —
gcTimeis when all subscribers gone triggers GC. If a component mounts/unmounts faster thangcTime, the cache survives. Suspensemode is opt-in (useSuspenseQuery); regularuseQuerydoes not throw promises.
What a senior is expected to say
- “Server state has different rules from client state. Once it lives in TanStack Query (or SWR/RTK Query), most of the Redux boilerplate disappears.”
- “Cache keys are the dedup contract; I pick them deliberately — domain noun first, narrowing left-to-right — so I can invalidate by prefix.”
- “After a mutation I usually invalidate; I
setQueryDataonly when I’m sure of the new shape, and pair it withinvalidateQueriesfor consistency.” - “I think about
staleTimedeliberately per resource — 0 for live data, minutes for slow-moving lists. Defaults to 0 means I’m always refetching on focus.” - “I use
prefetchQueryon hover/intent to make navigation instant, and I dehydrate on the server when SSR’ing.”
Cross-references
- Mutations and optimistic updates: 03_optimistic_updates.md
- Infinite queries and pagination: 04_pagination_and_infinite_queries.md
- Aborting on key change: 05_abort_and_race_conditions.md
- Server-state vs client-state framing: ../07_state_managers/
Further reading
- TanStack Query docs: https://tanstack.com/query/latest/docs/framework/react/overview
- Query Keys guide: https://tanstack.com/query/latest/docs/framework/react/guides/query-keys
- SWR (the simpler alternative): https://swr.vercel.app/
- RTK Query: https://redux-toolkit.js.org/rtk-query/overview