React data fetching & server state
Server state is not client state
Server state is owned by a remote server, fetched asynchronously, shared with other users and tabs, and can go stale without you knowing. Client state (a form input, an open modal, the selected tab) is local, synchronous, and authoritative. React Query and SWR are not state managers; they are an async cache for server state that dedupes requests, caches by key, revalidates in the background, and reports loading and error status. Client state still belongs in useState, useReducer, context, or a store.
Wrong "I fetch in a useEffect and hold the result in useState." Two components doing that fire two requests, neither revalidates when the row changes on the server, and you drift into hand-rolling invalidation. Right put the data behind a query key and let the cache dedupe and revalidate it.
The query cache: staleTime versus gcTime
These two timers are orthogonal, and the interview turns on not confusing them.
staleTimeis the freshness window. While data is fresh, a new observer or a window refocus serves the cache with no request. Once stale, those same triggers fire a background refetch: the old value shows instantly and swaps when fresh data arrives.gcTimeis the retention timer for inactive queries. It starts only once a query has zero observers (every component using it unmounted). When it elapses with no new subscriber, the entry is deleted. A query with even one active observer is never garbage collected.
TanStack Query v5 defaults: staleTime: 0 (stale on arrival), gcTime: 5 minutes, refetch on mount, focus, and reconnect all on. staleTime decides when to refetch, gcTime decides when to forget. Query keys are arrays that hash deterministically (object property order is ignored, array order matters); treat the key as the queryFn's dependency list, and changing a value in it refetches as a new entry.
Invalidation after a mutation
After a write, mark the affected server data stale and let it refetch:
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["todos"] })
invalidateQueries does two things at once: it marks matching queries stale (overriding staleTime) and refetches the active ones in the background. Matching is fuzzy by prefix, so ["todos"] also hits ["todos", { page: 1 }]; narrow it with exact: true or a predicate. Reach for it over refetchQueries after a mutation.
Optimistic updates and rollback
To make a write feel instant, paint the predicted result before the server answers, then reconcile once it does. There are two levels. The cheap one touches no cache: render the mutation's pending variables with a muted style while isPending, and there is nothing to undo when it settles. The full one writes the guess into the query cache so every observer of the key reflects it, which is a protocol rather than a single setQueryData call.
That protocol is four callbacks around a snapshot. onMutate cancels any in-flight refetch for the key (one already running would resolve after your write and clobber it), snapshots the current cache value with getQueryData, writes the predicted value, and hands the snapshot to the later callbacks as context. onError puts the snapshot back. onSettled invalidates the key on both the success and error paths so the server has the final word. Drop the snapshot and an error has nothing to restore; drop the invalidation and the cache drifts from the server.
The optimistic rollback race: why cancelQueries is mandatory
The rollback protocol has one non-obvious line. Without cancelQueries, a background refetch already in flight can resolve after your setQueryData and overwrite the optimistic value with pre-mutation server data, so the row flickers back before it settles. Cancelling in-flight fetches at the top of onMutate removes that racer. The callback order is onMutate, then onSuccess or onError, then onSettled; onSettled invalidates on both paths because even a success can carry a server-rewritten payload (a generated id, a timestamp) you have to re-sync. Mutations also do not retry by default, unlike queries (which retry three times with exponential backoff), so a transient failure rolls back at once unless you opt into retry.
Wrong "SWR taught me rollback is automatic, so I skip onError." SWR's rollbackOnError defaults to true; TanStack does not roll back for you. You own the snapshot and the restore.
UI-only versus cache-writing optimistic updates
The cache approach writes into the query cache, so every observer of the key reflects the change: use it when several components must show the pending result. The v5 UI-only approach skips cache surgery, reads the mutation's variables and isPending, and renders a temporary row that clears itself when the mutation settles, with isError to offer a retry. Prefer it when the pending value appears in one place. SWR's useSWRMutation covers the cache path through optimisticData, rollbackOnError, populateCache, and revalidate options.
Suspense, dedup, and the request waterfall
Deduplication is automatic: components mounting the same key while a fetch is in flight share one request (SWR adds a time-based dedupingInterval, default 2000ms). useSuspenseQuery differs from useQuery in three ways: data is guaranteed defined, loading is delegated to the nearest <Suspense>, and errors throw to the nearest error boundary. The catch is that it has no enabled option, so two suspense queries in one component fetch serially, a waterfall. Parallelize with useSuspenseQueries, or prefetch first: prefetchQuery returns Promise<void> and never throws, fetchQuery returns the data and can throw, ensureQueryData returns the cached value or fetches it.
Version and framework gotchas
Writing v4 syntax reads as out of date. The v5 renames:
| v4 | v5 |
|---|---|
| cacheTime | gcTime |
| status loading, isLoading = first load | status pending, isPending (isLoading is now isPending && isFetching) |
| keepPreviousData | placeholderData: keepPreviousData |
| positional useQuery(key, fn) | single object useQuery({ queryKey, queryFn }) |
| useErrorBoundary | throwOnError |
| query onSuccess / onError / onSettled | removed (still on mutations) |
Two framework facts sit next door. Next.js 15 flipped the App Router to uncached by default: fetch, GET Route Handlers, and the Client Router Cache for Page segments no longer cache, so opt back in with cache: "force-cache", next: { revalidate }, or export const dynamic = "force-static" (a newer Next moves to a separate "use cache" model, do not conflate them). React 19's use(promise) suspends on a promise, but the promise must be stable: create it inline in render and every render makes a new one, an infinite fallback. Create it in a Server Component and pass it down, and for real client fetching still prefer React Query or SWR for the caching and dedup use does not provide.
beta
The interviewer part is in the works.
The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.