skip to content

In TanStack Query v5, when do two query keys hit the same cache entry, and why might ['todo', 5] and ['todo', '5'] miss each other?

level: middleimportance: should knowfreq 50%

answer

  1. compared by a hash, not by reference
  2. JSON with sorted object properties
  3. array order still counts
  4. types survive serialization
  5. route params arrive as strings

basics

~20 s

Keys are compared by a deterministic hash: JSON.stringify with plain-object properties sorted. Object property order does not matter, but array order and value types do, so the number 5 and the string '5' produce different cache entries.

solid answer

~50 s

Each key is turned into a `queryHash` by `hashKey`, which is `JSON.stringify` with the properties of plain objects sorted. Two keys share an entry exactly when their hashes are equal. So `{ status, page }` and `{ page, status }` match, and a property whose value is `undefined` is dropped. Array positions are not sorted, so `['todos', status, page]` and `['todos', page, status]` differ. Types survive serialization: `5` hashes as `5` and `'5'` as `"5"`. That is the classic miss. A route param arrives as a string while the list page prefetched `['todo', 5]` with a number, so the detail page ignores the prefetched data and fetches again, and cache writes or invalidations made with one form never touch the other. Normalize values where the key is built. Keys must be arrays at the top level and JSON-serializable, and `queryKeyHashFn` exists for the rare exception.

code

ts · 10 lines
ts
import { hashKey } from '@tanstack/react-query'

hashKey(['todos', { status: 'open', page: 2 }]) // '["todos",{"page":2,"status":"open"}]'
hashKey(['todos', { page: 2, status: 'open' }]) // same string: same cache entry

hashKey(['todo', 5])   // '["todo",5]'
hashKey(['todo', '5']) // '["todo","5"]' -> a different entry

hashKey(['todos', 'open', 2]) // '["todos","open",2]'
hashKey(['todos', 2, 'open']) // '["todos",2,"open"]' -> array order matters

go deeper

for a junior

Know that query keys are arrays compared by content, and that every value that changes the data must be in the key, in a consistent form.

for a middle

Explain hashKey: JSON.stringify with sorted plain-object properties. Say which differences matter (array order, value types) and which do not (object property order, undefined properties).

for a senior

Diagnose a detail page that ignores prefetched data by comparing hashes, trace it to a string route param against a numeric id, and fix it in a typed key factory.

for a principal

Treat key construction as shared infrastructure with one normalization point per feature, and resist custom hash functions unless there is no plain-data alternative.

## From key to queryHash A TanStack Query **query key** is an array, for example `['todo', 5]`. The cache does not store entries by the array itself. It stores them by a string, the **`queryHash`**, computed from the key. By default that string comes from **`hashKey`**, which calls `JSON.stringify` with a replacer: whenever it meets a **plain object**, it rebuilds the object with its property names **sorted**. Everything else is serialized the way `JSON.stringify` always does it. The rule that follows is simple: **two keys hit the same cache entry if and only if their hashes are equal.** Reference identity plays no part. A key array created fresh on every render is fine as long as it serializes the same way. ## What counts as the same key | Key A | Key B | Same entry? | Why | |---|---|---|---| | `['todos', { status, page }]` | `['todos', { page, status }]` | yes | plain-object properties are sorted before hashing | | `['todos', { page, extra: undefined }]` | `['todos', { page }]` | yes | `JSON.stringify` drops object properties whose value is `undefined` | | `['todos', status, page]` | `['todos', page, status]` | no | array order is part of the identity | | `['todos', undefined, page]` | `['todos', page]` | no | `undefined` in an array becomes `null` and keeps its slot | | `['todo', 5]` | `['todo', '5']` | no | the number and the string serialize differently | The docs make the same point: objects are hashed deterministically, so property order does not matter, while array item order does. ## The string and number trap This mismatch is common because the two halves of an app often get the same id from different sources: 1. The list page receives todos from the API, where `id` is a **number**, and prefetches or seeds `['todo', 5]`. 2. The detail route reads its id from the URL. URL params are **strings**, so the detail hook uses `['todo', '5']`. 3. The hashes differ, `["todo",5]` against `["todo","5"]`, so the detail page finds nothing, shows its loading state and fetches data the cache already holds. 4. Any later `setQueryData` or invalidation written with the number form never reaches the string-keyed entry, and the reverse is also true, so the two copies drift apart. The fix is to **normalize at the point where keys are built**, ideally inside a key factory: `detail: (id: number) => ['todo', 'detail', id]`, with the route converting its param once (`Number(params.id)`). A typed factory makes the wrong type a compile error instead of a silent cache miss. ## Rules for key values - The key must be an **array at the top level**. v5 does not accept a bare string. - Every value must be **JSON-serializable** in a way that reflects the data you want: strings, numbers, booleans, `null`, arrays and plain objects. - Use **plain objects** for named parameters. Their properties are sorted, so `{ status, page }` and `{ page, status }` are the same key. - Keep the **type** of each position stable across the app. A number in one place and a string in another is two different keys. - Watch values that serialize surprisingly. A `Date` becomes its ISO string, and a `Map` or `Set` becomes `{}`, so different Maps collide on one entry. ## The escape hatch: queryKeyHashFn A query, or the `QueryClient` defaults, can set **`queryKeyHashFn`** to replace `hashKey` with your own function from key to string. That is useful only when a key legitimately has to contain values the default cannot serialize faithfully. It also becomes one more thing every key in the app depends on, so most codebases are better served by keeping keys plain. ## Why this matters beyond one hook The hash decides every cache hit, so the same mismatch that causes a missed prefetch also causes a missed cache write and a stale detail page after a mutation. That is why teams move key construction into one factory per feature. The one place that builds keys is the one place that normalizes their values.

  • Where would you normalize the id so the string and number forms never both appear?
    In the key factory, typed to accept one form, for example `detail: (id: number) => [...todoKeys.details(), id]`. Route components convert their param once before calling it. Every hook, prefetch and cache write then goes through the factory, so a stray string becomes a type error instead of a second cache entry.
  • Is it a problem that the key array is a new object on every render?
    No. The cache compares the serialized hash, not the array reference, so a key rebuilt on every render with the same contents maps to the same entry. It only becomes a problem when a value inside the key serializes differently each time, such as a timestamp taken during render.

saying these in an interview costs you the question

  • Query keys are compared by array reference, so they must be memoized.
  • Object property order inside a key creates different cache entries.
  • The id 5 and the id '5' are treated as the same key.
  • Array order in a key does not matter because keys are sorted.
  • Any JavaScript value can be put in a key and hashes faithfully.