| | |

TanStack Query 08 ⚛️ Query Invalidation — Refreshing stale data after mutations

⏱️ Estimated read: 12 min | |

A mutation succeeds on the server, but the UI does not change. The list still shows the old items, the detail page still shows the old values, and the user wonders whether the save button worked. This is the most common bug in applications that use TanStack Query, and it has a single cause: the mutation did not invalidate the queries that depend on the data it changed. Invalidation is the mechanism that connects a write to the reads that must be refreshed, and it operates on the query key hierarchy covered in earlier chapters.

The previous chapter introduced queryClient.invalidateQueries in the onSuccess callback. This chapter examines invalidation in depth: how it matches query keys, the difference between marking stale and actively refetching, the exact option, the predicate form, and the patterns for invalidating everything versus a targeted subset. It also covers the refetchType option and the interaction between invalidation and inactive queries.

Key point: invalidateQueries marks matching queries as stale and, by default, refetches the ones that are currently active. An inactive query—one with no mounted component observing it—is marked stale but not refetched until the next time it is observed. The matching is prefix-based: invalidateQueries({ queryKey: ['todos'] }) matches ['todos'], ['todos', 1], and ['todos', { page: 2 }]. The exact: true option restricts the match to the precise key.


Why invalidation requires deliberate design

The prefix-matching problem.

Invalidation uses the same hierarchical matching as the query keys themselves. A query key of ['todos'] matches every key that starts with 'todos', including detail views and filtered lists. This is usually what the developer wants after a mutation that affects the whole collection: a new todo changes the list, and the list key is the parent. But it can also invalidate more than intended. A mutation on a single todo that calls invalidateQueries({ queryKey: ['todos'] }) marks every todo query as stale, including the detail views for todos that were not changed. The exact: true option restricts the invalidation to the precise key, and a more specific key like ['todos', 'detail', id] restricts it to one item .

The active-versus-inactive problem.

invalidateQueries marks all matching queries as stale, but it only refetches the ones that are currently active. An active query is one with at least one mounted component observing it. An inactive query is marked stale but not refetched until a component mounts and observes it again. This behavior is correct for performance—there is no point in refetching data that nobody is looking at—but it can surprise developers who expect the cache to be immediately updated after invalidation. The refetchType option controls this behavior: 'active' (the default) refetches only active queries, 'inactive' refetches only inactive ones, 'all' refetches both, and 'none' marks stale without refetching anything .

The refetchType problem.

The default refetchType: 'active' is appropriate for most mutations. The user is looking at the list, the mutation succeeds, the list is active, and it refetches. But there are cases where the mutation should not trigger an immediate refetch: when the mutation response contains the updated data and the cache can be updated directly, when the user has navigated away from the affected view, or when the refetch would be expensive and the data can wait until the next mount. The refetchType: 'none' option marks the query stale without refetching, leaving the refetch to the next observation.


a. Basic invalidation and the exact option

The simplest invalidation call marks every query under a prefix as stale and refetches the active ones.

import { useMutation, useQueryClient } from '@tanstack/react-query'

function useAddTodo() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: addTodo,
    onSuccess: () => {
      // Invalidate all queries whose key starts with 'todos'
      // Active ones refetch immediately
      queryClient.invalidateQueries({ queryKey: ['todos'] })
    },
  })
}

This call matches ['todos'], ['todos', 1], ['todos', { status: 'done' }], and any other key that begins with 'todos'. The list query and the detail queries are all marked stale. The ones that are currently observed by mounted components refetch immediately; the others refetch when they are next observed .

The exact option changes the matching from prefix-based to precise. Only the query with the exact key is invalidated.

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

// Invalidate a specific todo detail and its children
queryClient.invalidateQueries({ queryKey: ['todos', 'detail', todoId] })

The second form matches ['todos', 'detail', 5] and ['todos', 'detail', 5, 'comments'], but not ['todos', 'detail', 6] or ['todos', 'list']. This is the surgical invalidation pattern: the mutation invalidates exactly the queries that depend on the changed data, and no others .


b. The refetchType option

The refetchType option controls which matching queries are refetched after being marked stale. The default is 'active'.

// Mark stale but do not refetch anything
queryClient.invalidateQueries({
  queryKey: ['todos'],
  refetchType: 'none',
})

// Refetch active queries only (the default)
queryClient.invalidateQueries({
  queryKey: ['todos'],
  refetchType: 'active',
})

// Refetch inactive queries only
queryClient.invalidateQueries({
  queryKey: ['todos'],
  refetchType: 'inactive',
})

// Refetch all matching queries, active and inactive
queryClient.invalidateQueries({
  queryKey: ['todos'],
  refetchType: 'all',
})

The 'none' value is useful when the mutation response contains the updated data and the cache is updated directly with setQueryData. In that case, the cache is already correct, and an immediate refetch would be redundant. The 'inactive' value is rarely used; it refetches queries that nobody is observing, which is usually wasted work. The 'all' value is used when the cache must be consistent immediately, regardless of what is mounted .


c. The predicate form for complex invalidation

The predicate option accepts a function that receives each query and returns a boolean. It allows invalidation based on the query’s key, state, or any other property.

// Invalidate queries whose key contains 'todos' as the first element
// and whose state is stale
queryClient.invalidateQueries({
  predicate: (query) => {
    return (
      query.queryKey[0] === 'todos' &&
      query.state.status === 'success'
    )
  },
})

// Invalidate all queries that have not fetched successfully
queryClient.invalidateQueries({
  predicate: (query) => query.state.status === 'error',
})

The predicate receives a Query object with queryKey, state, and other properties. It is more flexible than the key-prefix matching, but it is also less cacheable: TanStack Query cannot statically determine which queries match, so it evaluates the predicate against every query in the cache. For large caches, the predicate form is slower than the key form. It is appropriate when the invalidation condition cannot be expressed as a key prefix—for example, invalidating every query that belongs to a specific user, regardless of its resource type.


Complete Example Session

// ============================================
// PART 1: THE QUERY KEYS
// ============================================
// A hierarchical key factory from the earlier chapter
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,
}

// ============================================
// PART 2: THE ADD MUTATION
// ============================================
function useAddTodo() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: addTodo,
    onSuccess: () => {
      // A new todo affects the lists, not the existing details
      queryClient.invalidateQueries({ queryKey: todoKeys.lists() })
    },
  })
}

// ============================================
// PART 3: THE UPDATE MUTATION
// ============================================
function useUpdateTodo() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: updateTodo,
    onSuccess: (updated) => {
      // An update affects the specific detail and the lists
      queryClient.invalidateQueries({ queryKey: todoKeys.detail(updated.id) })
      queryClient.invalidateQueries({ queryKey: todoKeys.lists() })
    },
  })
}

// ============================================
// PART 4: THE DELETE MUTATION
// ============================================
function useDeleteTodo() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: deleteTodo,
    onSuccess: (_, deletedId) => {
      // A delete affects the lists and the deleted detail
      queryClient.invalidateQueries({ queryKey: todoKeys.lists() })
      // Remove the deleted detail from the cache entirely
      queryClient.removeQueries({ queryKey: todoKeys.detail(deletedId) })
    },
  })
}

// ============================================
// PART 5: INVALIDATION WITH refetchType: 'none'
// ============================================
function useToggleTodo() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: toggleTodo,
    onSuccess: (updated) => {
      // The response contains the updated todo
      // Update the cache directly instead of refetching
      queryClient.setQueryData(todoKeys.detail(updated.id), updated)
      // Mark the lists stale but do not refetch immediately
      queryClient.invalidateQueries({
        queryKey: todoKeys.lists(),
        refetchType: 'none',
      })
    },
  })
}

// ============================================
// PART 6: THE PREDICATE FORM
// ============================================
function useBulkUpdate() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: bulkUpdateTodos,
    onSuccess: () => {
      // Invalidate every todo query, regardless of its key shape
      queryClient.invalidateQueries({
        predicate: (query) => query.queryKey[0] === 'todos',
      })
    },
  })
}

The six parts demonstrate the full invalidation surface: the key factory, the add mutation that invalidates only lists, the update mutation that invalidates the detail and the lists, the delete mutation that invalidates the lists and removes the deleted detail, the toggle mutation that uses refetchType: 'none' and updates the cache directly, and the bulk mutation that uses the predicate form. The distinction between Parts 2 and 3 is the core lesson: a new item affects only the lists, while an update affects both the detail and the lists.


Quick Reference

invalidateQueries Options

OptionTypeDefaultPurpose
queryKeyArray—Prefix to match
exactBooleanfalseMatch the precise key only
refetchType'active' | 'inactive' | 'all' | 'none''active'Which queries to refetch
predicate(query) => boolean—Custom match function

refetchType Values

ValueBehavior
'active'Mark stale and refetch active queries
'inactive'Mark stale and refetch inactive queries
'all'Mark stale and refetch all matching queries
'none'Mark stale only; no refetch

Matching Behavior

Key['todos'] matches?['todos'], exact matches?
['todos']YesYes
['todos', 1]YesNo
['todos', 'list', { page: 1 }]YesNo
['todo', 1]NoNo

Best Practices

✅ Do This:

// Use the key factory for consistent invalidation
queryClient.invalidateQueries({ queryKey: todoKeys.lists() })  // ✅

// Invalidate the specific detail after an update
queryClient.invalidateQueries({ queryKey: todoKeys.detail(id) }) // ✅

// Use refetchType: 'none' when updating the cache directly
queryClient.setQueryData(key, data)
queryClient.invalidateQueries({ queryKey: key, refetchType: 'none' }) // ✅

// Use removeQueries for deleted items
queryClient.removeQueries({ queryKey: todoKeys.detail(deletedId) }) // ✅

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

// Return the promise to await the refetch
onSuccess: async () => { await queryClient.invalidateQueries(...) } // ✅

❌ Don’t Do This:

// Invalidate everything after every mutation
queryClient.invalidateQueries()    // Refetches the entire cache  # ❌

// Invalidate the whole 'todos' prefix after a single update
// It marks unaffected details stale unnecessarily                 # ❌

// Use refetchType: 'all' without a reason
// It refetches inactive queries that nobody is observing          # ❌

// Forget to invalidate after a mutation
useMutation({ mutationFn: updateTodo })    // UI stays stale      # ❌

// Use the predicate form for a simple prefix match
// The key form is faster and more cacheable                       # ❌

// Remove queries instead of invalidating them after an update
queryClient.removeQueries({ queryKey: ['todos'] })    // Loses cache # ❌

Common Pitfalls

PitfallWhy It HappensFix
UI does not update after mutationNo invalidation callAdd invalidateQueries in onSuccess
Refetch does not happen immediatelyQuery is inactiveUse refetchType: 'all' or wait for mount
Unrelated details refetchPrefix invalidation matches too muchUse exact: true or a specific key
Cache shows deleted itemInvalidation marks stale but does not removeUse removeQueries for deletions
Invalidation does nothingQuery key does not matchVerify the key hierarchy
Redundant refetch after direct cache updaterefetchType is 'active'Use refetchType: 'none'

Real-World Examples

1. Invalidate all todos

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

2. Invalidate only lists

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

3. Invalidate a specific detail

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

4. Exact match only

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

5. Mark stale without refetching

queryClient.invalidateQueries({ queryKey: ['todos'], refetchType: 'none' })

6. Refetch all matching queries

queryClient.invalidateQueries({ queryKey: ['todos'], refetchType: 'all' })

7. Remove a deleted item from the cache

queryClient.removeQueries({ queryKey: todoKeys.detail(deletedId) })

8. Update the cache directly

queryClient.setQueryData(todoKeys.detail(id), updated)

9. Predicate form

queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'todos' })

10. Await the refetch

onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ['todos'] }) }

Visual

Prefix Matching vs Exact Matching

┌──────────────────────────────────────────────────────────────┐
│  invalidateQueries({ queryKey: ['todos'] })                  │
│                                                              │
│  Cache keys:                                                 │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ ['todos']                    ← MATCH                   │  │
│  │ ['todos', 1]                 ← MATCH                   │  │
│  │ ['todos', 'list', {page:1}]  ← MATCH                   │  │
│  │ ['todos', 'detail', 5]       ← MATCH                   │  │
│  │ ['todo', 1]                  ← no match                │  │
│  │ ['users']                    ← no match                │  │
│  └────────────────────────────────────────────────────────┘  │
│                                                              │
│  invalidateQueries({ queryKey: ['todos'], exact: true })     │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ ['todos']                    ← MATCH                   │  │
│  │ ['todos', 1]                 ← no match                │  │
│  │ ['todos', 'list', ...]       ← no match                │  │
│  └────────────────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────────────────┘

refetchType Behavior

┌──────────────────────────────────────────────────────────────┐
│  WHICH QUERIES REFETCH AFTER INVALIDATION                    │
│                                                              │
│  refetchType: 'active' (default)                             │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ Active queries (mounted) → mark stale + refetch        │  │
│  │ Inactive queries         → mark stale only             │  │
│  └────────────────────────────────────────────────────────┘  │
│                                                              │
│  refetchType: 'none'                                         │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ All matching queries → mark stale only                 │  │
│  │ Refetch happens on next observation                    │  │
│  └────────────────────────────────────────────────────────┘  │
│                                                              │
│  refetchType: 'all'                                          │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ Active + inactive → mark stale + refetch               │  │
│  └────────────────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────────────────┘

Mutation-to-Invalidation Mapping

┌──────────────────────────────────────────────────────────────┐
│  EACH MUTATION INVALIDATES THE QUERIES IT AFFECTS            │
│                                                              │
│  Add todo:                                                   │
│  └── invalidate lists()                                      │
│      (new item appears in list; existing details unchanged)  │
│                                                              │
│  Update todo:                                                │
│  ├── invalidate detail(id)                                   │
│  └── invalidate lists()                                      │
│      (detail and list both reflect the change)               │
│                                                              │
│  Delete todo:                                                │
│  ├── invalidate lists()                                      │
│  └── removeQueries(detail(id))                               │
│      (item gone from list; detail cache removed)             │
│                                                              │
│  Bulk update:                                                │
│  └── invalidate all()                                        │
│      (every todo query may be affected)                      │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
MethodqueryClient.invalidateQueries
MatchingPrefix-based by default
Exact matchexact: true
Default refetchrefetchType: 'active'
No refetchrefetchType: 'none'
All refetchrefetchType: 'all'
Custom matchpredicate: (query) => boolean
Inactive queriesMarked stale, not refetched
Deletion patternremoveQueries for deleted items
Direct cache updatesetQueryData + refetchType: 'none'

Key takeaways:

  • Invalidation is prefix-based. invalidateQueries({ queryKey: ['todos'] }) matches every key that starts with 'todos', including detail views and filtered lists. The exact: true option restricts the match to the precise key .
  • Only active queries refetch by default. An inactive query is marked stale but not refetched until a component mounts and observes it. The refetchType option controls this behavior .
  • Match the invalidation to the mutation. An add affects the lists, an update affects the detail and the lists, and a delete affects the lists and removes the detail. Invalidating more than necessary marks unaffected queries stale and triggers unnecessary refetches .
  • Use removeQueries for deletions. Invalidation marks a query stale, but the stale data remains in the cache. For a deleted item, removeQueries removes the cache entry entirely, preventing the deleted item from reappearing if a component mounts before the refetch .
  • Use refetchType: 'none' when updating the cache directly. If the mutation response contains the updated data and setQueryData has already updated the cache, an immediate refetch is redundant. Marking stale without refetching preserves the cache consistency and avoids an unnecessary network request .
  • The predicate form is for complex conditions. It evaluates a function against every query in the cache. It is more flexible than key-prefix matching but slower for large caches. Use it when the invalidation condition cannot be expressed as a key prefix.

Remember: Invalidation is the bridge between a write and the reads that depend on it. The mutation succeeds, the invalidation marks the affected queries stale, and the active ones refetch. The key hierarchy determines what matches, and the refetchType option determines what refetches. Match the invalidation to the mutation’s actual impact: the lists for an add, the detail and the lists for an update, the lists and a cache removal for a delete. Without invalidation, the server has the new data but the UI shows the old. With it, the two stay in sync.


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!