Skip to content

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?

Pick one

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
Trace of the opening question
MomentCache entryRequests so farDrawer showsStep
tile and panel mount togethernone, one request out1—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

A query cache keeps each server response under its query key, an array compared by value. One key is one entry, whichever component asks.
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?

Pick one

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?

Pick one

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?

Pick one

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?

Code for this question
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] }),});
Pick one

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?

Pick one

Go deeper

Pro tips
  1. Ask the cache what it holds. queryClient.getQueryCache().getAll() lists each entry's queryHash, getObserversCount() (components reading it) and isStale(), 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.
  2. Give each test its own QueryClient with retry: 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 calls useQuery. Verify: TanStack's testing guide says "The easiest way to turn retries off is via the QueryClientProvider" and shows retry: false. Status: DOC; every script here also builds a fresh client per scenario.
Source

Checked against the tools and docs as of 2026-09-30.