| | |

TanStack Query 04 ⚛️ Query Keys — Structuring cache keys effectively

⏱️ Estimated read: 14 min | |

Query keys are the address system of the TanStack Query cache. Every useQuery call registers its data under a key, and every subsequent call with the same key retrieves that cached entry instead of fetching again. The key is not a label for display; it is the deterministic hash that the cache uses for lookup, sharing, invalidation, and garbage collection. A key that includes a variable produces a separate cache entry for each value of that variable. A key that omits a variable reuses the same entry regardless of the variable’s value, producing stale data that never refetches when the variable changes.

This chapter covers the rules of key structure: the array requirement, hierarchical organization, deterministic hashing, the exhaustive dependency rule, and the factory pattern that keeps keys consistent across a growing application. It builds on the useQuery state handling from the previous chapter; the key determines which state is rendered, and the state determines what the user sees.

Key point: A query key must be an array at the top level, must be JSON-serializable, and must include every variable that the query function reads. The cache hashes keys deterministically, so object property order within a key does not matter, but array element order does. Two keys that hash to the same value share the same cache entry.


Why key structure is a separate concern

The cache address problem.

TanStack Query does not inspect the query function to determine what data it returns. It cannot know that fetchUser(1) and fetchUser(2) return different data. The only signal it has is the query key. If both calls use ['user'] as the key, the second call returns the first call’s cached data, and the component renders user 1’s profile when it expected user 2’s . The key is the sole mechanism for distinguishing one cache entry from another. Its structure is therefore not a naming convention; it is the correctness contract of the cache.

The invalidation problem.

Mutations need to invalidate related queries after a successful write. invalidateQueries({ queryKey: ['todos'] }) invalidates every query whose key starts with 'todos'. This partial matching works only if keys are structured hierarchically: ['todos'], ['todos', { page: 1 }], and ['todos', { page: 2 }] all share the prefix and are all invalidated together . A flat key structure like ['todo-list'], ['todo-page-1'], and ['todo-page-2'] has no common prefix, so a single invalidation call cannot target them as a group. The key structure determines what operations are possible later.

The exhaustive dependency problem.

The @tanstack/eslint-plugin-query package includes an exhaustive-deps rule that flags query functions that read a variable not present in the key . This rule exists because the omission produces a bug that is invisible until a user changes the variable and sees the wrong data. The rule is not stylistic; it enforces the cache correctness contract. Every variable in the function, every variable in the key.


a. The array requirement and hierarchical structure

Query keys must be arrays. A string key like 'todos' was valid in v3 but was removed in v4 and later . The simplest valid key is an array with a single string. More complex keys add elements to the right, creating a hierarchy from generic to specific.

// Simple list key: no variables
useQuery({ queryKey: ['todos'], queryFn: fetchTodos })

// Detail key: adds the id as the second element
useQuery({ queryKey: ['todo', todoId], queryFn: () => fetchTodo(todoId) })

// Nested resource: adds a third element for the sub-resource
useQuery({ queryKey: ['todo', todoId, 'comments'], queryFn: () => fetchComments(todoId) })

// Filtered list: adds an object as the second element
useQuery({ queryKey: ['todos', { status: 'done' }], queryFn: () => fetchTodos({ status: 'done' }) })

The hierarchy matters for invalidation. invalidateQueries({ queryKey: ['todo'] }) matches ['todo', 5] and ['todo', 5, 'comments'] because both start with 'todo'. It does not match ['todos'] because the first element is different . This prefix matching is the mechanism that lets a mutation invalidate all related queries with a single call.

// Invalidates every query whose key starts with 'todos'
queryClient.invalidateQueries({ queryKey: ['todos'] })

// Invalidates only the exact key ['todos'], not ['todos', { page: 1 }]
queryClient.invalidateQueries({ queryKey: ['todos'], exact: true })

// Invalidates only queries with this specific filter object
queryClient.invalidateQueries({ queryKey: ['todos', { type: 'done' }] })

The exact: true option restricts matching to the precise key. Without it, ['todos'] matches every key that starts with 'todos', including filtered lists and detail views. The default partial matching is usually what you want after a mutation that affects all todo data; the exact option is for cases where only the unfiltered list needs refreshing.


b. Deterministic hashing and the order rules

TanStack Query serializes each key with JSON.stringify to produce a hash, and the hash is the cache lookup address . This hashing is deterministic but has two distinct order rules that are easy to confuse.

Object property order does not matter. The keys ['todos', { status, page }] and ['todos', { page, status }] produce the same hash and share the same cache entry . This is a deliberate design choice: it prevents a component from creating a duplicate cache entry simply because it constructed the filter object with properties in a different order. JSON.stringify normally preserves insertion order, but TanStack Query’s hashing function sorts object keys before serialization to ensure stability.

Array element order does matter. ['todos', status, page] and ['todos', page, status] produce different hashes and are different cache entries . The array is an ordered list, and the position of each element is part of its identity. This distinction is why filter objects should be passed as a single object element rather than spread across multiple array positions.

// These two keys are EQUAL: object property order is ignored
useQuery({ queryKey: ['todos', { status: 'done', page: 1 }], queryFn: fetchTodos })
useQuery({ queryKey: ['todos', { page: 1, status: 'done' }], queryFn: fetchTodos })

// These two keys are DIFFERENT: array element order matters
useQuery({ queryKey: ['todos', 'done', 1], queryFn: fetchTodos })
useQuery({ queryKey: ['todos', 1, 'done'], queryFn: fetchTodos })

Undefined values in object properties are ignored for hashing. ['todos', { status, page, other: undefined }] hashes the same as ['todos', { status, page }] . This means an object with an optional property set to undefined does not create a new cache entry. However, an explicit undefined array element, as in ['todos', undefined, page], does affect the hash because array positions are significant.


c. The exhaustive dependency rule and the factory pattern

The key must include every variable the query function reads. This rule is enforced by the ESLint plugin and verified by the cache’s behavior: if a variable changes and the key does not, the query does not refetch .

// The todoId is read by the function and must be in the key
function TodoDetail({ todoId }: { todoId: number }) {
  const { data } = useQuery({
    queryKey: ['todo', todoId],       // todoId is here
    queryFn: () => fetchTodo(todoId), // todoId is used here
  })
  // ...
}

Omitting todoId from the key produces a silent bug: when todoId changes, the key remains ['todo'], the cache returns the previous todo’s data, and the component displays the wrong content. The ESLint rule @tanstack/query/exhaustive-deps catches this at lint time, but the rule can only inspect the function’s closure references. A function that receives the key as a parameter and reads from it, as in the extracted-function pattern, must still have the variable in the key because the function receives the key’s contents.

// Extracted function reads from the query key
async function fetchTodo({ queryKey }: { queryKey: [string, number] }) {
  const [, todoId] = queryKey
  return fetch(`/api/todos/${todoId}`).then(r => r.json())
}

// The key must still contain the id
useQuery({ queryKey: ['todo', todoId], queryFn: fetchTodo })

As the application grows, ad-hoc key arrays become inconsistent. One component uses ['todos', { page }], another uses ['todos', page], and a third uses ['todo-list', { page }]. Invalidation calls that work for the first miss the second and third. The factory pattern solves this by centralizing key construction in a single module with a consistent shape .

// queryKeys/todos.ts
export const todoKeys = {
  all: ['todos'] as const,
  lists: () => [...todoKeys.all, 'list'] as const,
  list: (filters: { status?: string; page?: number }) =>
    [...todoKeys.lists(), filters] as const,
  details: () => [...todoKeys.all, 'detail'] as const,
  detail: (id: number) => [...todoKeys.details(), id] as const,
  comments: (id: number) => [...todoKeys.detail(id), 'comments'] as const,
}

// In components
useQuery({ queryKey: todoKeys.list({ status: 'done' }), queryFn: fetchDoneTodos })
useQuery({ queryKey: todoKeys.detail(5), queryFn: () => fetchTodo(5) })
useQuery({ queryKey: todoKeys.comments(5), queryFn: () => fetchComments(5) })

// In mutations
queryClient.invalidateQueries({ queryKey: todoKeys.all })        // Everything
queryClient.invalidateQueries({ queryKey: todoKeys.lists() })    // All lists
queryClient.invalidateQueries({ queryKey: todoKeys.detail(5) })  // One todo

The factory’s hierarchy is the invalidation hierarchy. todoKeys.all is ['todos'], and every other key starts with it. Invalidating todoKeys.all clears everything. Invalidating todoKeys.lists() clears only the list queries, leaving detail views intact. Invalidating todoKeys.detail(5) clears only that one todo and its nested comments, because the comments key starts with the detail key .


Complete Example Session

// ============================================
// PART 1: THE FACTORY DEFINITION
// ============================================
// All todo-related keys are constructed here
// The hierarchy enables partial invalidation
const todoKeys = {
  all: ['todos'] as const,
  lists: () => [...todoKeys.all, 'list'] as const,
  list: (filters: Record<string, unknown>) =>
    [...todoKeys.lists(), filters] as const,
  details: () => [...todoKeys.all, 'detail'] as const,
  detail: (id: number) => [...todoKeys.details(), id] as const,
  comments: (id: number) => [...todoKeys.detail(id), 'comments'] as const,
}

// ============================================
// PART 2: LIST QUERY WITH FILTERS
// ============================================
// The filters object is a single array element
// Object property order within it does not affect the hash
function TodoList({ status }: { status: string }) {
  const { data } = useQuery({
    queryKey: todoKeys.list({ status }),  // ['todos', 'list', { status }]
    queryFn: () => fetchTodos({ status }),
  })
  // ...
}

// ============================================
// PART 3: DETAIL QUERY WITH ID
// ============================================
// The id is a separate array element
// Changing the id creates a new cache entry and a new fetch
function TodoDetail({ id }: { id: number }) {
  const { data } = useQuery({
    queryKey: todoKeys.detail(id),  // ['todos', 'detail', id]
    queryFn: () => fetchTodo(id),
  })
  // ...
}

// ============================================
// PART 4: NESTED RESOURCE QUERY
// ============================================
// Comments are nested under the todo detail key
// Invalidating the detail also invalidates its comments
function TodoComments({ id }: { id: number }) {
  const { data } = useQuery({
    queryKey: todoKeys.comments(id),  // ['todos', 'detail', id, 'comments']
    queryFn: () => fetchComments(id),
  })
  // ...
}

// ============================================
// PART 5: MUTATION WITH TARGETED INVALIDATION
// ============================================
function useUpdateTodo() {
  const queryClient = useQueryClient()
  return useMutation({
    mutationFn: updateTodo,
    onSuccess: (updated) => {
      // Invalidate the specific detail and all lists
      // Other details remain cached and valid
      queryClient.invalidateQueries({ queryKey: todoKeys.detail(updated.id) })
      queryClient.invalidateQueries({ queryKey: todoKeys.lists() })
    },
  })
}

// ============================================
// PART 6: BULK INVALIDATION
// ============================================
// After a bulk operation, invalidate everything under 'todos'
function useBulkUpdateTodos() {
  const queryClient = useQueryClient()
  return useMutation({
    mutationFn: bulkUpdateTodos,
    onSuccess: () => {
      // One call clears lists, details, and nested comments
      queryClient.invalidateQueries({ queryKey: todoKeys.all })
    },
  })
}

The six parts demonstrate the factory pattern from definition through list, detail, and nested queries, then show the invalidation calls that the hierarchy enables. The mutation on a single todo invalidates only that detail and the lists; a bulk operation invalidates the entire todos prefix.


Quick Reference

Key Structure Rules

RuleRequirementExample
Top levelMust be an array['todos'], not 'todos'
SerializationMust be JSON-serializableNo functions, no class instances
UniquenessMust uniquely identify the dataInclude all variables from queryFn
HierarchyGeneric to specific, left to right['todos', 'detail', id]
Object orderDoes not affect hash{ a, b } equals { b, a }
Array orderDoes affect hash['a', 'b'] ≠ ['b', 'a']

Factory Shape

MethodReturnsInvalidates
all['todos']Everything under todos
lists()['todos', 'list']All lists, no details
list(filters)['todos', 'list', filters]One filtered list
details()['todos', 'detail']All details, no lists
detail(id)['todos', 'detail', id]One detail and its children

Invalidation Matching

CallMatchesDoes Not Match
['todos']['todos'], ['todos', 'list'], ['todos', 'detail', 1]['todo', 1], ['users']
['todos'], exact: true['todos'] only['todos', 'list']
['todos', { status: 'done' }]Keys with that exact objectKeys with different objects

Best Practices

✅ Do This:

// Use array keys at the top level
queryKey: ['todos']                                      // ✅

// Include every variable from the query function
queryKey: ['todo', todoId], queryFn: () => fetchTodo(todoId) // ✅

// Use a factory for consistent key shapes
queryKey: todoKeys.detail(id)                            // ✅

// Invalidate hierarchically after mutations
invalidateQueries({ queryKey: todoKeys.lists() })         // ✅

// Use exact: true when only the root key should match
invalidateQueries({ queryKey: ['todos'], exact: true })  // ✅

// Pass filters as a single object element
queryKey: ['todos', { status, page }]                    // ✅

❌ Don’t Do This:

// Use a string key (v3 syntax)
queryKey: 'todos'                                        // ❌

// Omit a variable from the key
queryKey: ['todo'], queryFn: () => fetchTodo(todoId)     // ❌ Stale data

// Spread filters across array positions
queryKey: ['todos', status, page]                        // ❌ Order-sensitive

// Use non-serializable values in the key
queryKey: ['todos', new Date()]                          // ❌ Not JSON-safe

// Invalidate everything with a broad prefix when only one is needed
invalidateQueries({ queryKey: ['todos'] })  // Invalidates details too // ❌ (sometimes)

// Rely on object property order for identity
queryKey: ['todos', { a: 1, b: 2 }]  // Same as { b: 2, a: 1 }      // ❌ (order ignored)

Common Pitfalls

PitfallWhy It HappensFix
Wrong user’s data displayedtodoId omitted from keyInclude all variables in key
Duplicate cache entriesFilter object constructed differentlyPass as single object; order is ignored
Invalidation misses queriesFlat key structure with no common prefixUse hierarchical factory
Refetch does not trigger on variable changeVariable not in keyAdd variable to key
exact invalidation affects too muchMissing exact: trueUse exact option for root-only match
Key hash collisionNon-serializable values in keyUse strings, numbers, plain objects

Real-World Examples

1. Simple list key

queryKey: ['todos']

2. Detail key with id

queryKey: ['todo', todoId]

3. Filtered list key

queryKey: ['todos', { status: 'done', page: 1 }]

4. Nested resource key

queryKey: ['user', userId, 'posts']

5. Factory definition

const userKeys = {
  all: ['users'] as const,
  detail: (id: number) => [...userKeys.all, 'detail', id] as const,
}

6. Invalidating a specific detail

queryClient.invalidateQueries({ queryKey: userKeys.detail(5) })

7. Invalidating all lists

queryClient.invalidateQueries({ queryKey: userKeys.lists() })

8. Exact invalidation of root only

queryClient.invalidateQueries({ queryKey: ['todos'], exact: true })

9. Extracted function reading key

async function fetchTodo({ queryKey }: { queryKey: [string, number] }) {
  const [, id] = queryKey
  return fetch(`/api/todos/${id}`).then(r => r.json())
}

10. ESLint exhaustive-deps rule

{
  "rules": {
    "@tanstack/query/exhaustive-deps": "error"
  }
}

Visual

Hierarchical Key Structure

┌──────────────────────────────────────────────────────────────┐
│  KEY HIERARCHY ENABLES PARTIAL MATCHING                      │
│                                                              │
│  ['todos']                          ← all                    │
│    ├── ['todos', 'list']            ← lists()                │
│    │     ├── ['todos', 'list', { status: 'done' }]           │
│    │     └── ['todos', 'list', { status: 'open' }]           │
│    └── ['todos', 'detail']          ← details()              │
│          ├── ['todos', 'detail', 1]                          │
│          │     └── ['todos', 'detail', 1, 'comments']        │
│          └── ['todos', 'detail', 2]                          │
│                                                              │
│  invalidateQueries({ queryKey: ['todos'] })                  │
│    → invalidates every key in this tree                      │
│                                                              │
│  invalidateQueries({ queryKey: ['todos', 'list'] })          │
│    → invalidates both filtered lists, not details            │
│                                                              │
│  invalidateQueries({ queryKey: ['todos', 'detail', 1] })     │
│    → invalidates detail 1 and its comments, not detail 2     │
└──────────────────────────────────────────────────────────────┘

Deterministic Hashing Rules

┌──────────────────────────────────────────────────────────────┐
│  WHAT AFFECTS THE HASH                                       │
│                                                              │
│  ✅ Array element order matters:                             │
│     ['todos', status, page] ≠ ['todos', page, status]        │
│                                                              │
│  ❌ Object property order does NOT matter:                   │
│     ['todos', { status, page }]                              │
│       = ['todos', { page, status }]                          │
│                                                              │
│  ✅ Undefined object properties are ignored:                 │
│     ['todos', { status, page }]                              │
│       = ['todos', { status, page, other: undefined }]        │
│                                                              │
│  ✅ Explicit undefined array elements count:                 │
│     ['todos', undefined, page] ≠ ['todos', page]             │
│                                                              │
│  Rule: put filters in objects, put identifiers in positions. │
└──────────────────────────────────────────────────────────────┘

Exhaustive Dependencies

┌──────────────────────────────────────────────────────────────┐
│  THE QUERY FUNCTION AND THE KEY MUST AGREE                   │
│                                                              │
│  ✅ CORRECT:                                                 │
│  queryKey: ['todo', todoId]                                  │
│  queryFn: () => fetch(`/api/todos/${todoId}`)                │
│      │              │                                        │
│      └──────────────┘                                        │
│         todoId is in both                                    │
│                                                              │
│  ❌ INCORRECT:                                               │
│  queryKey: ['todo']                                          │
│  queryFn: () => fetch(`/api/todos/${todoId}`)                │
│      │              │                                        │
│      └──────────────┘                                        │
│         todoId is used but not in key → stale data           │
│                                                              │
│  When todoId changes:                                        │
│  - Key stays ['todo']                                        │
│  - Cache returns previous todo                               │
│  - No refetch occurs                                         │
│  - Component renders wrong data                              │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
Key typeArray at top level; string keys removed in v4+
SerializationMust be JSON-serializable
HashingDeterministic; object property order ignored, array order significant
Exhaustive ruleEvery variable from queryFn must be in the key
HierarchyGeneric to specific: ['entity', 'sub', id]
Factory patternCentralized key construction with a consistent shape
InvalidationPartial matching on prefix; exact: true for precise match
v4+ changeString keys removed; arrays required

Key takeaways:

  • The key is the cache address, not a label. Two queries with the same key share one cache entry. If the data should be different, the key must be different .
  • Include every variable the function reads. Omitting a variable produces stale data that never refetches when the variable changes. The ESLint exhaustive-deps rule catches this .
  • Object property order does not affect the hash. { status, page } and { page, status } are the same key. Array element order does affect the hash. Put filters in objects, identifiers in positions .
  • Use a factory for consistency. A centralized key factory ensures every component uses the same shape, and every invalidation call targets the correct prefix .
  • Hierarchy enables surgical invalidation. A mutation on one detail should invalidate that detail and the lists, not every other detail. The factory’s nested structure makes this possible .
  • Partial matching is the default. invalidateQueries({ queryKey: ['todos'] }) clears everything under todos. Use exact: true when only the root key should match .
  • Keys must be serializable. No functions, no class instances, no circular references. Plain strings, numbers, booleans, and objects are safe .

Remember: Query keys are not decorative strings. They are the deterministic hashes that the cache uses for every lookup, share, invalidation, and garbage collection. A key that omits a variable is a cache collision that renders the wrong data. A key that is not hierarchical is an invalidation call that cannot reach its targets. Structure the keys from generic to specific, include every dependency, and centralize the shape in a factory. The cache will then behave predictably: same key, same data; different key, different data; prefix invalidation, targeted refresh.


Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!