Query keys: the cache's identity
- Explain why the query key — not the
queryFn— decides which cache entry auseQuerycall reads, fetches and shares - Diagnose a key that misses a
queryFninput from its symptoms: a filter change that never fetches, or two components sharing one entry - Predict whether two keys are the same entry: object property order and
undefinedproperties don't count; array order,5vs"5"and per-render values do - Write a key factory ordered general → specific, and predict which cached keys a prefix such as
todoKeys.lists()matches
Read
The key is the cache entry — the queryFn only fills it
FlagTanStack Query (this track targets v5) keeps one cache per QueryClient. Picture a map:
each entry is filed under the hash of a query key and holds that query's data, error and
status. When a component calls useQuery({ queryKey, queryFn }), the library:
- hashes
queryKeyand looks up that entry; - returns whatever data the entry already has, straight away;
- runs
queryFnif the entry has no data yet (or its data is stale), and stores the result under that key.
The queryFn is not part of the identity. It is only "how to fill this entry". So two calls
with the same key are one entry, whatever their functions do:
function OpenTodos() {
const { data } = useQuery({ queryKey: ["todos"], queryFn: () => fetchTodos("open") });
return <TodoList todos={data} />;
}
function DoneTodos() {
const { data } = useQuery({ queryKey: ["todos"], queryFn: () => fetchTodos("done") });
return <TodoList todos={data} />;
}
Render both on one page and only one request goes out — the library dedupes fetches for
the same key — so both components show the same list: whichever one mounted first ran its
queryFn (with OpenTodos first, the open todos), and the second queryFn didn't run at all.
The rule that prevents this is the one the docs state outright: if the queryFn reads a
variable, the variable goes in the key.
// ❌ status changes, the key doesn't: same entry, no new request
useQuery({ queryKey: ["todos"], queryFn: () => fetchTodos(status) });
// ✅ one entry per status
useQuery({ queryKey: ["todos", { status }], queryFn: () => fetchTodos(status) });
With status in the key, the key behaves like a dependency array. Change status and the
hook moves to a different entry: if that entry already has data you see it immediately,
and if it has none (or it's stale) the queryFn runs and fills it.
Two shape rules: the key must be an array at the top level (since v4), and everything in
it must be serialisable with JSON.stringify.
A key factory, and what each prefix reaches
FlagOnce a feature has a list, a filtered list and a detail view, hand-written keys drift: one
file writes ["todo", id], another ["todos", "detail", id], and an invalidation misses one
of them. The fix is to spell every key in one place, general → specific:
type TodoFilters = { status: "open" | "done"; page: number };
export const todoKeys = {
all: ["todos"] as const,
lists: () => [...todoKeys.all, "list"] as const,
list: (filters: TodoFilters) => [...todoKeys.lists(), filters] as const,
details: () => [...todoKeys.all, "detail"] as const,
detail: (id: number) => [...todoKeys.details(), id] as const,
};
Line by line:
allis the root. Every todo key starts with it, so one prefix reaches the whole feature.lists()anddetails()are the branches. They exist mostly to be matched: you rarely query["todos", "list"]itself, but you will invalidate it.list(filters)ends with the filter object, so every input offetchTodos(filters)is in the key, and property order doesn't matter.as constkeeps each key a readonly tuple type instead ofstring[], which helps when you pass it around typed.
Keys are matched as prefixes, element by element from the start. That's how
invalidateQueries, removeQueries and the other filter-taking methods decide what to touch:
| Filter key | Matches |
|---|---|
todoKeys.all | every list and every detail |
todoKeys.lists() | every list, any filters — no details |
todoKeys.list({ status: "open", page: 1 }) | that list (and any longer key starting with it) |
todoKeys.detail(5) | todo 5 (and any longer key starting with it) — no other todo |
["todos"] does not match ["user", "todos"] (position 0 differs) or ["todos-archive"]
(strings aren't prefix-matched). Add exact: true when you mean only the key itself.
Co-locating the key with its function
The key and the queryFn must stay in sync, so v5's queryOptions helper lets you define them
together. At runtime it returns what you pass in; in TypeScript it ties the data type to the key:
import { queryOptions } from "@tanstack/react-query";
export const todoQueries = {
list: (filters: TodoFilters) =>
queryOptions({ queryKey: todoKeys.list(filters), queryFn: () => fetchTodos(filters) }),
detail: (id: number) =>
queryOptions({ queryKey: todoKeys.detail(id), queryFn: () => fetchTodo(id) }),
};
// in a component
const { data } = useQuery(todoQueries.detail(id));
// elsewhere — the same key, typed: data is inferred as a Todo
queryClient.setQueryData(todoQueries.detail(id).queryKey, updatedTodo);
Components never spell a key again; they ask the factory. The next units lean on this: an invalidation after a save is one line, and it can't miss a variant.
Keys that lie — production traps in hashing and matching
FlagA key is wrong in one of two directions. Too coarse: two different pieces of data share an entry (the missing-variable bug). Too fine: one piece of data is spread over several entries, or the key changes when the data didn't. The coarse kind shows wrong data; the fine kind shows stale data, duplicate requests, or a request loop.
"5" is not 5
The list renders rows with numeric ids. The detail route reads its id from the URL, where everything is a string:
// list row: prime the detail cache so the detail page opens instantly
queryClient.setQueryData(todoKeys.detail(todo.id), todo); // ["todos", "detail", 5]
// detail route, key written by hand
const { id } = useParams(); // "5"
useQuery({ queryKey: ["todos", "detail", id], queryFn: () => fetchTodo(id) }); // another entry
The detail page never sees the primed data, fetches again, and a later
invalidateQueries({ queryKey: todoKeys.detail(5) }) misses it. Normalise at the boundary
(Number(id)) and go through the factory, whose id: number parameter makes TypeScript refuse
the string.
A value that changes every render
const since = new Date().toISOString(); // ❌ a new value on every render
useQuery({ queryKey: ["feed", { since }], queryFn: () => fetchFeed(since) });
Each response re-renders the component a few milliseconds later, which builds a new key, so a new entry and another request. Run headlessly against a fake fetch that takes 20 ms, this made 44 requests and 44 cache entries in one second, and it doesn't stop. Fix the value once (in state, or let the server decide what "latest" means) — a key holds inputs, not clocks.
Values JSON can't represent
The hash comes from the key's JSON form (with object keys sorted), so non-JSON values collapse:
MapandSethash as{}— every Set-valued key is the same entry. Pass[...selectedTags].sort()instead.undefinedinside an array hashes asnull, so["todos", undefined]and["todos", null]are one entry. (Inside an object, anundefinedproperty is dropped.)- A
Datehashes as its ISO string, which is fine for a fixed date and fatal fornew Date().
Prefixes reach into objects
Matching is partial inside objects too: ["todos", "list", { status: "open" }] matches
["todos", "list", { status: "open", page: 2 }]. That's handy — "every page of open todos" —
and it means a filter object that's a subset of another is a prefix of it.
What does not go in the key
The docs' rule is "every variable the queryFn uses that changes." The function you call
and a stable API client are not data inputs. @tanstack/eslint-plugin-query has an
exhaustive-deps rule that flags a missing variable — the lint version of this whole unit —
with an allowlist for things like an API client.
Quiz
<OpenTodos /> calls useQuery({ queryKey: ['todos'], queryFn: () => fetchTodos('open') }) and <DoneTodos /> calls useQuery({ queryKey: ['todos'], queryFn: () => fetchTodos('done') }). Both mount on the same page. What happens?
Clicking the Done tab updates status and the tab highlight, but the list still shows open todos and no request appears in the network tab. The query is useQuery({ queryKey: ['todos', 'list'], queryFn: () => fetchTodos(status) }). What's going on?
Which pair of keys refers to the same cache entry?
Flagapi is a module-level HTTP client. Which values belong in the key for queryFn: ({ signal }) => api.get('/projects', { params: { orgId, archived }, signal })?
With the reading's factory (lists: () => [...todoKeys.all, 'list']), which cached queries does invalidateQueries({ queryKey: todoKeys.lists() }) match? Pick all that apply.
Lists are keyed ['todos', 'list', filters], but a teammate keyed the detail ['todo', id]. After a bulk mark all done, the code calls invalidateQueries({ queryKey: ['todos'] }). What breaks?
The list primes the detail cache with setQueryData(todoKeys.detail(todo.id), todo), where todo.id is 5. The detail route builds its key by hand as ['todos', 'detail', params.id], with params.id read from the URL. What does the user see when they open todo 5?
Fill in the blank
Complete the factory: each list is keyed by its filters, and each detail sits under the feature's root.
Flagexport const todoKeys = { all: ["todos"] as const, lists: () => [...todoKeys.all, "list"] as const, list: (filters: TodoFilters) => [...todoKeys.lists(), ] as const, details: () => [..., "detail"] as const, detail: (id: number) => [...todoKeys.details(), ] as const, };
Make the queryFn take its filters from the key (built by the factory as ['todos', 'list', filters]), so it can't read an input the key doesn't hold.
useQuery({ queryKey: todoKeys.list(filters), queryFn: ({ , signal }) => fetchTodos(queryKey[], { signal }), });
Invalidate only the bare ['todos'] entry — not every key that starts with it.
queryClient.invalidateQueries({ queryKey: ["todos"], : true });
Define the key and its function together, so no component can pair a key with the wrong function.
Flagconst todoDetail = (id: number) => ({ queryKey: todoKeys.detail(id), queryFn: () => fetchTodo(id) }); const { data } = useQuery(todoDetail(id));
Predict the output
const requests: string[] = [];
const fetchTodos = (status: string) => { requests.push(status); return api.todos(status); };
function OpenTodos() {
const { data } = useQuery({ queryKey: ["todos"], queryFn: () => fetchTodos("open") });
return <p>Open: {data?.map((t) => t.title).join(", ")}</p>;
}
function DoneTodos() {
const { data } = useQuery({ queryKey: ["todos"], queryFn: () => fetchTodos("done") });
return <p>Done: {data?.map((t) => t.title).join(", ")}</p>;
}
// <OpenTodos /> then <DoneTodos /> mount together on a fresh QueryClient.
// The server has one open todo ("Buy milk") and one done todo ("File taxes").import { hashKey } from "@tanstack/react-query";
const a = hashKey(["todos", { status: "open", page: 1, sort: undefined }]);
const b = hashKey(["todos", { page: 1, status: "open" }]);
const c = hashKey(["tags", new Set(["urgent"])]);
const d = hashKey(["tags", new Set(["someday"])]);
console.log(a === b, c === d);function Feed() {
const since = new Date().toISOString();
const { data } = useQuery({
queryKey: ["feed", { since }],
queryFn: () => fetchFeed(since), // the server answers in about 20 ms
});
return <FeedList items={data} />;
}
// Feed mounts once; nothing else happens on the page.Challenge
Fix the keys behind a list + detail screen
FlagA todo screen with an Open / Done filter and a detail panel. Both queries take their keys
from one factory, todoKeys — and the factory is wrong in three ways:
- Clicking Done highlights the button, but the list keeps showing open todos, and no request goes out.
- Selecting a second todo keeps showing the first todo's details.
- After a bulk change,
invalidateQueries({ queryKey: todoKeys.all })refreshes the list but leaves the open detail stale.
Fix the keys. The components and their queryFns are fine as they are.
The tests render TodoScreen with their own QueryClient and a fake server they control
through api.ts.
Your task
Edit TodoScreen.tsx so the tests pass:
- Switching the filter shows that filter's todos
- Switching back to a filter you've already viewed shows its cached list at once (each filter has its own cache entry)
- Selecting a todo shows that todo's details
invalidateQueries({ queryKey: todoKeys.all })refetches the list and the open detailinvalidateQueries({ queryKey: todoKeys.lists() })refetches lists only, not the detail