Query invalidation is the process of marking queries as stale to trigger refetches. It’s essential for keeping your UI in sync with server state after mutations.
What is Invalidation?
When you invalidate a query:
- The query is marked as stale (regardless of its
staleTime)
- If the query is currently being rendered, it refetches in the background
From query.ts:380-384:
invalidate(): void {
if (!this.state.isInvalidated) {
this.#dispatch({ type: 'invalidate' })
}
}
And the invalidation is handled in the reducer at query.ts:665-669:
case 'invalidate':
return {
...state,
isInvalidated: true,
}
Basic Invalidation
Invalidate Specific Queries
import { useMutation, useQueryClient } from '@tanstack/react-query'
function CreatePost() {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: createPost,
onSuccess: () => {
// Invalidate and refetch posts
queryClient.invalidateQueries({ queryKey: ['posts'] })
},
})
return <button onClick={() => mutation.mutate(newPost)}>Create</button>
}
Invalidate Multiple Queries
// Invalidate all queries starting with 'posts'
queryClient.invalidateQueries({ queryKey: ['posts'] })
// This invalidates:
// ['posts']
// ['posts', 'list']
// ['posts', 'detail', 123]
// ['posts', { status: 'published' }]
Invalidation API
From queryClient.ts:293-313, the invalidateQueries method:
invalidateQueries<TTaggedQueryKey extends QueryKey = QueryKey>(
filters?: InvalidateQueryFilters<TTaggedQueryKey>,
options: InvalidateOptions = {},
): Promise<void> {
return notifyManager.batch(() => {
this.#queryCache.findAll(filters).forEach((query) => {
query.invalidate()
})
if (filters?.refetchType === 'none') {
return Promise.resolve()
}
return this.refetchQueries(
{
...filters,
type: filters?.refetchType ?? filters?.type ?? 'active',
},
options,
)
})
}
Refetch Type
Control which queries are refetched after invalidation:
// Only refetch active queries (currently mounted)
queryClient.invalidateQueries({
queryKey: ['posts'],
refetchType: 'active' // default
})
// Refetch all queries, even inactive ones
queryClient.invalidateQueries({
queryKey: ['posts'],
refetchType: 'all'
})
// Don't refetch, just mark as stale
queryClient.invalidateQueries({
queryKey: ['posts'],
refetchType: 'none'
})
Active queries are queries currently being observed (rendered in components). Inactive queries are in the cache but not currently observed.
Query Filters
Exact Matching
// Only invalidate queries with exactly ['posts', 'list']
queryClient.invalidateQueries({
queryKey: ['posts', 'list'],
exact: true
})
Prefix Matching (Default)
// Invalidate all queries starting with ['posts']
queryClient.invalidateQueries({ queryKey: ['posts'] })
// Invalidates:
// ['posts'] ✓
// ['posts', 'list'] ✓
// ['posts', 123] ✓
// ['users'] ✗
Predicate Function
// Invalidate queries matching a custom condition
queryClient.invalidateQueries({
predicate: (query) => {
return query.queryKey[0] === 'posts' &&
query.state.data?.some((post) => post.authorId === authorId)
},
})
Filter by Status
// Only invalidate stale queries
queryClient.invalidateQueries({
queryKey: ['posts'],
stale: true,
})
// Only invalidate inactive queries
queryClient.invalidateQueries({
queryKey: ['posts'],
type: 'inactive',
})
Common Invalidation Patterns
After Mutations
The most common use case - invalidate after creating, updating, or deleting data:
const createPostMutation = useMutation({
mutationFn: createPost,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] })
},
})
const updatePostMutation = useMutation({
mutationFn: updatePost,
onSuccess: (data) => {
// Invalidate list and detail
queryClient.invalidateQueries({ queryKey: ['posts'] })
queryClient.invalidateQueries({ queryKey: ['posts', data.id] })
},
})
const deletePostMutation = useMutation({
mutationFn: deletePost,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] })
},
})
Hierarchical Invalidation
const postKeys = {
all: ['posts'] as const,
lists: () => [...postKeys.all, 'list'] as const,
list: (filters: Filters) => [...postKeys.lists(), filters] as const,
details: () => [...postKeys.all, 'detail'] as const,
detail: (id: number) => [...postKeys.details(), id] as const,
}
// Invalidate everything
queryClient.invalidateQueries({ queryKey: postKeys.all })
// Invalidate all lists
queryClient.invalidateQueries({ queryKey: postKeys.lists() })
// Invalidate specific detail
queryClient.invalidateQueries({ queryKey: postKeys.detail(123) })
const updateUserMutation = useMutation({
mutationFn: updateUser,
onSuccess: (data) => {
// Invalidate user and all related queries
queryClient.invalidateQueries({ queryKey: ['users', data.id] })
queryClient.invalidateQueries({ queryKey: ['posts', { authorId: data.id }] })
queryClient.invalidateQueries({ queryKey: ['comments', { authorId: data.id }] })
},
})
Invalidation vs. Refetch
invalidateQueries
Marks queries as stale and refetches active queries by default:
queryClient.invalidateQueries({ queryKey: ['posts'] })
- Marks queries as stale immediately
- Refetches active queries automatically
- Inactive queries refetch when they become active
refetchQueries
Forces an immediate refetch without marking as stale:
queryClient.refetchQueries({ queryKey: ['posts'] })
From queryClient.ts:315-339:
refetchQueries<TTaggedQueryKey extends QueryKey = QueryKey>(
filters?: RefetchQueryFilters<TTaggedQueryKey>,
options: RefetchOptions = {},
): Promise<void> {
const fetchOptions = {
...options,
cancelRefetch: options.cancelRefetch ?? true,
}
const promises = notifyManager.batch(() =>
this.#queryCache
.findAll(filters)
.filter((query) => !query.isDisabled() && !query.isStatic())
.map((query) => {
let promise = query.fetch(undefined, fetchOptions)
if (!fetchOptions.throwOnError) {
promise = promise.catch(noop)
}
return query.state.fetchStatus === 'paused'
? Promise.resolve()
: promise
}),
)
return Promise.all(promises).then(noop)
}
Use invalidateQueries for most cases. Use refetchQueries when you need to force an immediate refetch regardless of staleness.
Reset Queries
Reset queries to their initial state:
queryClient.resetQueries({ queryKey: ['posts'] })
From queryClient.ts:258-276:
resetQueries<TTaggedQueryKey extends QueryKey = QueryKey>(
filters?: QueryFilters<TTaggedQueryKey>,
options?: ResetOptions,
): Promise<void> {
const queryCache = this.#queryCache
return notifyManager.batch(() => {
queryCache.findAll(filters).forEach((query) => {
query.reset()
})
return this.refetchQueries(
{
type: 'active',
...filters,
},
options,
)
})
}
This:
- Resets the query state to initial
- Refetches active queries
Automatic Invalidation
Window Focus
Queries automatically refetch when the window regains focus (if stale):
useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
refetchOnWindowFocus: true, // default
})
Network Reconnection
Queries refetch when the network reconnects (if stale):
useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
refetchOnReconnect: true, // default
})
Mount
Queries refetch when new instances mount (if stale):
useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
refetchOnMount: true, // default
})
Manual Invalidation Strategy
Conservative Approach
Invalidate only what changed:
const updatePostMutation = useMutation({
mutationFn: updatePost,
onSuccess: (updatedPost) => {
// Only invalidate the specific post and lists
queryClient.invalidateQueries({ queryKey: ['posts', 'list'] })
queryClient.invalidateQueries({ queryKey: ['posts', updatedPost.id] })
},
})
Aggressive Approach
Invalidate everything related:
const updatePostMutation = useMutation({
mutationFn: updatePost,
onSuccess: () => {
// Invalidate all post-related queries
queryClient.invalidateQueries({ queryKey: ['posts'] })
},
})
Hybrid Approach
Optimistic update + invalidation:
const updatePostMutation = useMutation({
mutationFn: updatePost,
onMutate: async (updatedPost) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['posts', updatedPost.id] })
// Optimistically update
const previous = queryClient.getQueryData(['posts', updatedPost.id])
queryClient.setQueryData(['posts', updatedPost.id], updatedPost)
return { previous }
},
onError: (err, updatedPost, context) => {
// Rollback
queryClient.setQueryData(['posts', updatedPost.id], context?.previous)
},
onSettled: (updatedPost) => {
// Always refetch to ensure sync
queryClient.invalidateQueries({ queryKey: ['posts', updatedPost.id] })
queryClient.invalidateQueries({ queryKey: ['posts', 'list'] })
},
})
Cancelling Queries
Cancel in-flight queries before invalidating:
// Cancel all posts queries before invalidating
await queryClient.cancelQueries({ queryKey: ['posts'] })
queryClient.invalidateQueries({ queryKey: ['posts'] })
This is useful for optimistic updates to prevent race conditions.
Invalidation Options
queryClient.invalidateQueries(
{ queryKey: ['posts'] },
{
cancelRefetch: true, // Cancel current refetch if running
throwOnError: false, // Don't throw on error
}
)
Best Practices
1. Use Query Key Factories
const keys = {
posts: {
all: ['posts'] as const,
lists: () => [...keys.posts.all, 'list'] as const,
list: (filters: Filters) => [...keys.posts.lists(), filters] as const,
details: () => [...keys.posts.all, 'detail'] as const,
detail: (id: number) => [...keys.posts.details(), id] as const,
},
}
// Easy to invalidate at any level
queryClient.invalidateQueries({ queryKey: keys.posts.all })
queryClient.invalidateQueries({ queryKey: keys.posts.lists() })
queryClient.invalidateQueries({ queryKey: keys.posts.detail(123) })
2. Invalidate in onSettled
Use onSettled to invalidate whether the mutation succeeds or fails:
useMutation({
mutationFn: updatePost,
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] })
},
})
3. Be Specific When Possible
// Good: specific invalidation
queryClient.invalidateQueries({ queryKey: ['posts', postId] })
// Less ideal: broad invalidation (but sometimes necessary)
queryClient.invalidateQueries({ queryKey: ['posts'] })
4. Combine with Optimistic Updates
useMutation({
mutationFn: updatePost,
onMutate: async (updated) => {
await queryClient.cancelQueries({ queryKey: ['posts', updated.id] })
const previous = queryClient.getQueryData(['posts', updated.id])
queryClient.setQueryData(['posts', updated.id], updated)
return { previous }
},
onError: (err, updated, context) => {
queryClient.setQueryData(['posts', updated.id], context?.previous)
},
onSettled: (updated) => {
queryClient.invalidateQueries({ queryKey: ['posts', updated?.id] })
},
})
Always await cancelQueries before performing optimistic updates to prevent race conditions between your optimistic update and in-flight requests.