open atlas
↑ Back to track
React, zero to senior RCT · 03 · 03

Query libraries: queryKey identity, the two clocks, and what TanStack Query actually saves you

TanStack Query treats server data as a cache, not state: queryKey is cache identity (dedup, sharing), staleTime and gcTime are two independent clocks (refetch vs memory), refetch-on-focus keeps data live, and mutations plus invalidateQueries close the write loop.

RCT Middle ◷ 18 min
Level
FoundationsJuniorMiddleSenior

The backend team escalated first: “/api/me is 30% of our traffic — why does your SPA call it six times per navigation?” The frontend audit was embarrassing in a familiar way. The header fetched the current user for the avatar. The sidebar fetched it for permissions. Three widgets fetched it for personalization. Each component had its own dutiful useEffect, its own loading flag, its own copy of the answer in its own useState — six requests for one resource, none of them shared, none of them ever updated after mount. A user who renamed themselves in settings saw the old name in the header until they hit F5. The team’s first instinct was a hand-rolled context-plus-cache; two sprints later it had a memory leak, no retry logic, and a race bug the original effects never had. What they were rebuilding, badly, was a server-state cache — request deduplication, staleness tracking, background refetch, garbage collection. That is the actual product of TanStack Query and its siblings: not “fetching made easy”, but the admission that server data is a cache with cache problems, and cache problems have forty years of known solutions.

queryKey is cache identity, not a label

The mental shift: server data is not your state — it is a cache of someone else’s state. TanStack Query makes that explicit. Every query is identified by its queryKey, an array hashed structurally (object key order does not matter, array order does): ['todos', { status, page }]. The key is the cache’s address. Every component calling useQuery with the same key reads the same cache entry — six components asking for ['me'] produce one request, deduplicated in flight, and one shared answer. The discipline that follows: every variable your queryFn reads must be in the key, exactly like a useEffect dependency array. Leave page out of the key and page 2 overwrites page 1 under the same address; the symptom is “wrong data flickers in”, the diagnosis is almost always a key that under-describes the request.

function Todos({ status, page }) {
  const { data, isPending, isError, error } = useQuery({
    queryKey: ["todos", { status, page }],   // identity = all inputs
    queryFn: ({ signal }) =>
      fetchTodos({ status, page, signal }),  // abort wired for free
    staleTime: 30_000,                        // fresh for 30s
  });
  // isPending → first load, no cached data yet
  // data can be present *and* stale: shown instantly, refetched behind
}

function AddTodo() {
  const queryClient = useQueryClient();
  const mutation = useMutation({
    mutationFn: createTodo,
    onSuccess: () =>
      queryClient.invalidateQueries({ queryKey: ["todos"] }), // prefix match
  });
  return <button onClick={() => mutation.mutate({ title: "ship" })}>Add</button>;
}

The two clocks: staleTime decides refetching, gcTime decides memory

Two independent timers govern every cache entry, and conflating them is the number-one TanStack Query misunderstanding. staleTime answers “is this data fresh enough to serve without asking the server again?” While an entry is fresh, any mount or focus event serves it from cache, full stop — zero requests. Once it goes stale, the data is still served instantly, but a background refetch fires on the next trigger: a new component mounting, the window regaining focus, the network reconnecting. That is stale-while-revalidate: the user always sees data immediately; freshness arrives quietly behind it. gcTime answers a different question: “after the last component using this entry unmounts, how long do we keep it in memory?” An active entry is never collected; an inactive one survives gcTime (default 5 minutes) so that navigating back within that window is an instant cache hit instead of a spinner.

The defaults are deliberately aggressive: staleTime: 0 — everything is stale immediately, so every mount and every window focus triggers a background refetch; plus failed queries retry 3 times with exponential backoff. Teams discover this as “why is my API getting hammered?” — the answer is not to disable refetchOnWindowFocus globally (that focus refetch is what keeps a dashboard truthful when the user comes back from lunch) but to set staleTime honestly per resource: 30 seconds for a todo list, 10 minutes for a country list, 0 for a stock price. The two clocks fail differently when misconfigured: staleTime too high serves wrong data; gcTime too high holds dead data in memory; setting gcTime lower than staleTime quietly defeats the point, since the entry is collected before its freshness ever matters to a returning visitor.

Quiz

A query has staleTime: 60_000 and gcTime: 300_000. The component unmounts after 10 seconds; the user navigates back 40 seconds later. What happens at remount?

Mutations close the loop — and the library’s real invoice

Reads are half the story. useMutation wraps a write and, in onSuccess, you reconcile the cache with the new server reality — most simply with invalidateQueries({ queryKey: ['todos'] }), which prefix-matches every key starting with 'todos', marks them stale, and immediately refetches the active ones (inactive entries just get the stale flag and refetch on next use). This is the piece the effect pattern never had: after a POST, your hand-rolled useState caches across five components are all silently wrong, and no amount of effect hygiene fixes data that no longer reflects the server.

Tally what the library actually saves versus last lesson’s baseline: in-flight deduplication (six subscribers, one request) and shared cache entries; the race guard built in — responses are matched to their request, and queryFn receives an AbortSignal wired to query cancellation; retries with exponential backoff; lifecycle — staleness, focus/reconnect refetch, garbage collection; and the write path — invalidation, plus optimistic updates (next lesson). Together, these five properties mean that every component reading the same data stays synchronized across writes, network hiccups, and tab re-focuses without a line of boilerplate — drop any one of them and you are back to writing it yourself. The honest costs: a dependency in the critical path (~12 kB gzipped core), key-design discipline (keys are your cache schema — centralize them in a factory, because scattered ad-hoc keys make targeted invalidation guesswork), and defaults you must consciously tune. The tradeoff is the same one as ever: you are not avoiding a cache — you are choosing between a tested one and the one you will write by accident.

Quiz

A todo list is paginated: queryFn fetches /api/todos?page=N, but the queryKey is just ['todos']. The user flips from page 1 to page 2. What does the cache do, and what does the user see?

Recall before you leave
  1. 01
    Explain staleTime versus gcTime precisely: what question does each clock answer, what are the defaults, and what distinct failure does each cause when misconfigured?
  2. 02
    A teammate says: "TanStack Query is just useEffect with extra steps — I can write the fetch myself." Itemize what the library replaces, mapping each item to the failure it prevents.
Recap

Query libraries start from a reframe: server data is not component state, it is a client-side cache of state owned by the server — and caches have known problems with known solutions. In TanStack Query the queryKey is cache identity: an array hashed structurally, where every input the queryFn reads must appear (an under-keyed paginated query lets page 2 overwrite page 1 under one address), and every component using the same key shares one deduplicated entry — six avatar/permission/personalization readers of ['me'] collapse into one request. Each entry runs on two independent clocks: staleTime gates revalidation — fresh entries serve mounts and focus events from cache with zero requests, stale entries serve instantly while a background refetch fires on the next trigger (stale-while-revalidate); gcTime gates memory — active entries are never collected, inactive ones survive five minutes by default so back-navigation is an instant hit. The defaults are aggressive (staleTime 0, three retries with exponential backoff, refetch on focus and reconnect), so refetch storms are fixed by honest per-resource staleTime, not by disabling the focus refetch that keeps long-lived tabs truthful. Writes close the loop: useMutation plus invalidateQueries prefix-matches affected keys, marks them stale, and refetches active ones — the reconciliation step the effect pattern never had. The invoice: a ~12 kB dependency, key discipline (centralize key factories), and consciously tuned defaults — against the cache you would otherwise write by accident. Now when you see an API being hammered or data that lies until F5, your first question is: what is the staleTime for this resource, and does the queryKey include every input the fetcher reads?

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 7 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.