TanStack Query 08 ⚛️ Query Invalidation — Refreshing stale data after mutations
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
| Option | Type | Default | Purpose |
|---|---|---|---|
queryKey | Array | — | Prefix to match |
exact | Boolean | false | Match the precise key only |
refetchType | 'active' | 'inactive' | 'all' | 'none' | 'active' | Which queries to refetch |
predicate | (query) => boolean | — | Custom match function |
refetchType Values
| Value | Behavior |
|---|---|
'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'] | Yes | Yes |
['todos', 1] | Yes | No |
['todos', 'list', { page: 1 }] | Yes | No |
['todo', 1] | No | No |
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
| Pitfall | Why It Happens | Fix |
|---|---|---|
| UI does not update after mutation | No invalidation call | Add invalidateQueries in onSuccess |
| Refetch does not happen immediately | Query is inactive | Use refetchType: 'all' or wait for mount |
| Unrelated details refetch | Prefix invalidation matches too much | Use exact: true or a specific key |
| Cache shows deleted item | Invalidation marks stale but does not remove | Use removeQueries for deletions |
| Invalidation does nothing | Query key does not match | Verify the key hierarchy |
| Redundant refetch after direct cache update | refetchType 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
| Item | Value |
|---|---|
| Method | queryClient.invalidateQueries |
| Matching | Prefix-based by default |
| Exact match | exact: true |
| Default refetch | refetchType: 'active' |
| No refetch | refetchType: 'none' |
| All refetch | refetchType: 'all' |
| Custom match | predicate: (query) => boolean |
| Inactive queries | Marked stale, not refetched |
| Deletion pattern | removeQueries for deleted items |
| Direct cache update | setQueryData + refetchType: 'none' |
Key takeaways:
- Invalidation is prefix-based.
invalidateQueries({ queryKey: ['todos'] })matches every key that starts with'todos', including detail views and filtered lists. Theexact: trueoption 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
refetchTypeoption 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
removeQueriesfor deletions. Invalidation marks a query stale, but the stale data remains in the cache. For a deleted item,removeQueriesremoves 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 andsetQueryDatahas 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.
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!