Data Fetching with TanStack
Data Fetching with TanStack

Query keys: the cache's identity

Full · ~30 min
After this unit you can
  • Explain why the query key — not the queryFn — decides which cache entry a useQuery call reads, fetches and shares
  • Diagnose a key that misses a queryFn input 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 undefined properties don't count; array order, 5 vs "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

Simple
1 / 2

The key is the cache entry — the queryFn only fills it

Flag

TanStack 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:

  1. hashes queryKey and looks up that entry;
  2. returns whatever data the entry already has, straight away;
  3. runs queryFn if 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.

Medium

A key factory, and what each prefix reaches

Flag

Once 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:

  • all is the root. Every todo key starts with it, so one prefix reaches the whole feature.
  • lists() and details() 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 of fetchTodos(filters) is in the key, and property order doesn't matter.
  • as const keeps each key a readonly tuple type instead of string[], 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 keyMatches
todoKeys.allevery 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.

Complex

Keys that lie — production traps in hashing and matching

Flag

A 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:

  • Map and Set hash as {} — every Set-valued key is the same entry. Pass [...selectedTags].sort() instead.
  • undefined inside an array hashes as null, so ["todos", undefined] and ["todos", null] are one entry. (Inside an object, an undefined property is dropped.)
  • A Date hashes as its ISO string, which is fine for a fixed date and fatal for new 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?

Flag

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?

Flag

Which pair of keys refers to the same cache entry?

Flag

api is a module-level HTTP client. Which values belong in the key for queryFn: ({ signal }) => api.get('/projects', { params: { orgId, archived }, signal })?

Flag

With the reading's factory (lists: () => [...todoKeys.all, 'list']), which cached queries does invalidateQueries({ queryKey: todoKeys.lists() }) match? Pick all that apply.

Flag

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?

Flag

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?

Flag

Fill in the blank

Complete the factory: each list is keyed by its filters, and each detail sits under the feature's root.

Flag
export 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.

Flag
useQuery({
  queryKey: todoKeys.list(filters),
  queryFn: ({ , signal }) => fetchTodos(queryKey[], { signal }),
});

Invalidate only the bare ['todos'] entry — not every key that starts with it.

Flag
queryClient.invalidateQueries({ queryKey: ["todos"], : true });

Define the key and its function together, so no component can pair a key with the wrong function.

Flag
const 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").
Flag
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);
Flag
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.
Flag

Challenge

Fix the keys behind a list + detail screen

Flag

A 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 detail
  • invalidateQueries({ queryKey: todoKeys.lists() }) refetches lists only, not the detail

Loading the in-browser editor…

Further reading