Client-side server-state cache
The key is the identity, staleTime decides when to ask again, and gcTime decides how long nobody may need an entry before it goes.
Predict first
Before you read: predict
ExamHall's proctor console uses TanStack Query v5 with its default options. For candidate c-41, the status tile and the alert panel each call useQuery({ queryKey: ['candidate', 'c-41', 'status'], queryFn: getStatus }), and both mount in the same render. Once getStatus has answered, the proctor opens the status drawer, which makes the same call. The status on the server changes between requests.
How many status requests reach the server in total, and what does the drawer show when it opens?
The 30-second version
A query cache keeps server data under a key. Components that ask for the same key share one entry, and a request already in flight. Data counts as fresh for staleTime, default 0; stale data is still shown at once, and a newly mounted reader refetches it. gcTime deletes unused entries. After a write, invalidate the keys, or patch optimistically.
The key is the identity, staleTime decides when to ask again, and gcTime decides how long nobody may need an entry before it goes.
Step through the code
Trace of the opening question
Play options
Ready: press Play or step with Next frame
Row 1 of 4 · Moment tile and panel mount together
Starting row · computed by comparing each row with the one before
- Moment: tile and panel mount together
- Cache entry: none, one request out
- Requests so far: 1
- Drawer shows: —
Rule · step 3
The first component to need a key fetches with its function. A component that mounts while that request is in flight shares it.
- Moment
- tile and panel mount together
- Cache entry
- none, one request out
- Requests so far
- 1
- Drawer shows
- —
- Step
- 3
| Moment | Cache entry | Requests so far | Drawer shows | Step |
|---|---|---|---|---|
| tile and panel mount together | none, one request out | 1 | — | 3 |
| the answer arrives (changed) | "2 alerts", stale at once (changed) | 1 | — | 4 |
| the drawer mounts (changed) | stale, shown; refetch out (changed) | 2 (changed) | "2 alerts" (changed) | 5 |
| the refetch answers (changed) | "3 alerts" for all three (changed) | 2 | "3 alerts" (changed) | 6 |
Space plays or pauses · ←/→ one row · Home/End first/last
Rows come from the pack's own trace table.
How it works
Step 1 of 12
See it run
Not in this run
In the kitchen
- one card per name-and-town → one entry per key (step 1)
Analogy: A directory-enquiries operator and her card box
Callers ask for a subscriber's number; she keeps a card for each name-and-town she has looked up.
| At the switchboard | In the query cache |
|---|---|
| one card per name-and-town | one entry per key (step 1) |
| two callers ask for the same name while she is already looking it up; both hear that one lookup | a shared in-flight request (step 3) |
| a card written within the hour is read out without opening the directory | fresh, no request (step 4) |
| an older card is read out at once, and she checks the directory and corrects it | stale shown, refetched (step 5) |
| cards nobody has asked for in a week go in the bin | gcTime (step 7) |
| the exchange's notice "district 4 renumbered" marks every district-4 card "check"; callers still on the line are checked now | invalidation by prefix (step 9) |
| a subscriber's new number goes on the card at once, marked provisional, and the old card goes back if the exchange rejects it | optimistic patch, rollback (steps 10–11) |
In real terms: each key holds one remembered answer, shared by every component that asks, reused while fresh, refreshed while shown, and dropped when unused.
Breaks down when: an operator notices an answer that does not fit the question. The cache never checks: whatever a fetch returns becomes the key's entry (step 6). Wrong inference: "the cache knows which data each component wanted."
Contrasting case: an operator with no card box, who opens the directory for every call, even for a name asked a minute ago. That is a fetch in each component's effect.
Examples, run for real
4.1 ExamHall: the proctor console's Tiles tab (EXH-1470)
Forty candidate tiles each need ECON-201's exam metadata. The proctor opens Tiles, then Alerts, Tiles, Alerts, Tiles.
4.1 fetch in each tile's effect, into a store metadata requests per Tiles visit 40, 40, 40 (total 120)
4.1 useQuery, default staleTime 0 metadata requests per Tiles visit 1, 1, 1 (total 3)
4.1 useQuery, staleTime: Infinity (MR !1670) metadata requests per Tiles visit 1, 0, 0 (total 1)
4.1 alert list (staleTime 0, every version) requests per Alerts visit 1, 1 · first frame spinner | 3 alerts (cached)
A store filled from forty effects fetches forty times. One key makes it one request (steps 1, 3). Exam metadata cannot change during a sitting, so MR !1670 marks it fresh forever. The alert list keeps staleTime 0: shown at once, refetched on every return (step 5).
4.2 Proof domain, catalog: one product page on the clock
A product page uses staleTime: 60_000. The shopper leaves and returns three times.
4.2 first visit requests 1 · shows spinner > €49
4.2 back after 30 s requests 0 · shows €49
4.2 back 3 min later requests 1 · shows €49 > €45
4.2 back 7 min later requests 1 · shows spinner > €44
4.2 keys {category, size} in either order: 1 entry · ['products','boots',42] vs ['products',42,'boots']: 2 entries · 4 components -> 3 entries, 3 requests
At 30 s the entry is fresh (step 4); at 3 minutes it is stale, shown and refetched (step 5); 7 minutes unused passes gcTime, so the entry is gone (step 7). Object keys match in any order; array order counts (step 2).
4.3 ExamHall: acknowledging an alert (EXH-1471)
A proctor acknowledges alert a-17; the tile patches optimistically. Another proctor has already escalated it, so the server answers 409.
4.3 patch, no rollback (before MR !1671) open > acknowledged · alert requests 1
4.3 patch + rollback + invalidate, server OK open > acknowledged · alert requests 2
4.3 patch + rollback + invalidate, server 409 open > acknowledged > open > escalated · alert requests 2
Without a rollback the tile keeps a status the server refused. MR !1671 restores the snapshot, then invalidates, so the tile ends as the server says (steps 10–11).
Try it
Answer first, then check. Nothing is saved.
Question 1 of 5
A catalog's product page renders PriceBox, which calls useQuery({ queryKey: ['product', id], queryFn: () => api.summary(id) }) with the default options. api.summary returns the price and stock, with no reviews field. After it has answered, the shopper opens the Reviews tab, which mounts ReviewList: useQuery({ queryKey: ['product', id], queryFn: () => api.withReviews(id) }). It renders the number of data.reviews, or "No reviews yet" when that field is missing. api.withReviews returns the price, the stock and the product's 12 reviews.
What does ReviewList show?
Question 2 of 5
A category page lists boots with useQuery({ queryKey: ['products', 'boots'], queryFn, staleTime: 30 * 60_000, gcTime: 2 * 60_000 }). A shopper loads the list, opens a product (the list unmounts, and nothing else reads that key) and comes back 20 minutes later.
What does the list show, and how many list requests does her return send?
Question 3 of 5
A ticketing app's query cache holds five entries. On screen: ['orders', 'list', 'open'], ['orders', 'detail', 17] and ['ordersSummary']. Cached but not on screen: ['orders', 'list', 'refunded'] and ['orders', 'list', 'cancelled']. After a refund succeeds, the app calls queryClient.invalidateQueries({ queryKey: ['orders'] }).
How many requests does that call send right away?
Question 4 of 5
A music app's track page reads useQuery({ queryKey: ['track', id], queryFn: getTrack }) with the default options. The heart button's mutation:
Listeners who tap the heart just after switching back to the tab see it turn red, then grey, then red again. The server stored the like the first time.
Which one change makes the heart turn red at the tap and stay red?
useMutation({ mutationFn: () => api.like(id), onMutate: async () => { const prev = qc.getQueryData(['track', id]); qc.setQueryData(['track', id], { ...prev, liked: true }); return { prev }; }, onError: (err, vars, ctx) => qc.setQueryData(['track', id], ctx.prev), onSettled: () => qc.invalidateQueries({ queryKey: ['track', id] }),});Question 5 of 5
An e-commerce cart shows data.total from useQuery({ queryKey: ['cart'], queryFn: getCart }). Promotions can change a cart's total when its contents change. The cart holds €60.00, and the shopper adds boots priced €25.00. "Add to cart" is a useMutation whose call returns only { ok: true }. The team's goal: after an add, the total updates without a reload, and the cart never shows a total the shopper would not pay.
Which update meets the goal?
Go deeper
Pro tips
- Ask the cache what it holds.
queryClient.getQueryCache().getAll()lists each entry'squeryHash,getObserversCount()(components reading it) andisStale(), so a surprise refetch or a missing entry can be read off instead of guessed. When: a screen fetches more, or less, than you expect (steps 1, 4, 7). Verify:["facets","boots"] observers=1 stale=true·["brand","b-3"] observers=0 stale=false. - Give each test its own
QueryClientwithretry: false. A shared client carries one test's cache into the next, and the default three retries with backoff can make an error test time out. When: writing component tests for code that callsuseQuery. Verify: TanStack's testing guide says "The easiest way to turn retries off is via the QueryClientProvider" and showsretry: false. Status: DOC; every script here also builds a fresh client per scenario.
Source
- TanStack Query v5, "Important Defaults": https://tanstack.com/query/latest/docs/framework/react/guides/important-defaults · read 2026-09-30 from the docs' source file on GitHub. "Query instances via
useQuery… by default consider cached data as stale"; stale queries refetch when "New instances of the query mount", "The window is refocused", "The network is reconnected"; "inactive" queries "are garbage collected after 5 minutes". Supports steps 4, 5, 7. Valid as of 2026-09-30. - "Caching Examples" (the curriculum's URL): https://tanstack.com/query/latest/docs/framework/react/guides/caching · same commit (
sources/tq-caching.md). "that data is immediately returned from the cache"; "The new instance triggers a new network request using its query function"; thegcTimetimeout is set once "there are no more active instances". Valid as of 2026-09-30. - "Query Keys": https://tanstack.com/query/latest/docs/framework/react/guides/query-keys · same commit (
sources/tq-query-keys.md). "Query Keys are hashed deterministically!"; "Array item order matters!"; keys "should include any variables you use in your query function that change". Supports steps 1–2. Valid as of 2026-09-30. - "Query Invalidation" and "Filters": https://tanstack.com/query/latest/docs/framework/react/guides/query-invalidation, https://tanstack.com/query/latest/docs/framework/react/guides/filters · same commit (
sources/tq-query-invalidation.md,sources/tq-filters.md). An invalidated query "is marked as stale", and "If the query is currently being rendered viauseQuery… it will also be refetched in the background"; "invalidate any queries that start withtodosin their query key". Supports step 9. Valid as of 2026-09-30. - "Optimistic Updates": https://tanstack.com/query/latest/docs/framework/react/guides/optimistic-updates · same commit (
sources/tq-optimistic-updates.md). "Cancel any outgoing refetches (so they don't overwrite our optimistic update)", "Snapshot the previous value", roll back inonError, "Always refetch after error or success". Supports steps 10–11. Valid as of 2026-09-30. - "Testing": https://tanstack.com/query/latest/docs/framework/react/guides/testing · same commit (
sources/tq-testing.md). Pro tip 2. - Not taught here, though listed in the curriculum row: RFC 5861 (
stale-while-revalidateas an HTTP directive) belongs tosd-cdn-edge-caching; react.dev'suseOptimisticbelongs toreact-actions-forms. This lesson's "stale shown, refetched" is the query cache's behaviour, not a header.
Related lessons
Checked against the tools and docs as of 2026-09-30.