frontend / apis data fetching / 02_tanstack_query.md

TanStack Query — Cache Keys, Invalidation, Prefetch

5 min read source

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:

  1. Dedup — ten components requesting the same useQuery(["user", id]) produce one network call.
  2. Cache + revalidation — the response is cached by key, and refetched per a policy (on focus, on reconnect, on mount, after staleTime).
  3. Loading/error/data state machineisLoading, 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 (or placeholderData: previous) keeps the previous page visible during paginated refetches.
  • Mutations don’t auto-invalidate — you must call invalidateQueries (or update with setQueryData).
  • Garbage collection on unmountgcTime is when all subscribers gone triggers GC. If a component mounts/unmounts faster than gcTime, the cache survives.
  • Suspense mode is opt-in (useSuspenseQuery); regular useQuery does 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 setQueryData only when I’m sure of the new shape, and pair it with invalidateQueries for consistency.”
  • “I think about staleTime deliberately per resource — 0 for live data, minutes for slow-moving lists. Defaults to 0 means I’m always refetching on focus.”
  • “I use prefetchQuery on hover/intent to make navigation instant, and I dehydrate on the server when SSR’ing.”

Cross-references

Further reading