Cache Invalidation in React Query: Patterns
After a mutation succeeds on the server, your client's cache is stale. Without invalidation, users see outdated data until they manually refresh. React Query's invalidateQueries API lets you surgically mark queries as stale, triggering refetches and keeping your UI in sync with the server—without hard page reloads. This article covers the most practical invalidation patterns and when each is best suited.
Understanding Query Invalidation
Invalidation marks a cached query as stale, triggering an immediate or lazy refetch depending on your configuration. It is different from deletion: the query key still exists, but React Query knows the data is no longer trustworthy. When a component renders that query, it will refetch fresh data from the server.
Invalidation is typically triggered in an onSuccess callback after a mutation completes. For example, after creating a post, you invalidate the posts list so the UI shows the new entry.
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
export default function CreatePostForm() {
const queryClient = useQueryClient();
const createMutation = useMutation({
mutationFn: async (title) => {
const res = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});
if (!res.ok) throw new Error('Failed to create post');
return res.json();
},
onSuccess: (newPost) => {
// Invalidate the posts list; queries with key 'posts' will refetch
queryClient.invalidateQueries({ queryKey: ['posts'] });
console.log('Post created and posts list invalidated:', newPost);
},
});
const handleSubmit = (title) => {
createMutation.mutate(title);
};
return (
<button onClick={() => handleSubmit('My Post')}>
{createMutation.isPending ? 'Creating...' : 'Create Post'}
</button>
);
}
Query Key Patterns for Targeted Invalidation
Query keys are hierarchical arrays. ['posts'] matches all queries starting with posts, including ['posts', { filter: 'active' }] and ['posts', 5]. This lets you invalidate at the right granularity.
| Mutation | Query Key to Invalidate | Effect |
|---|---|---|
| Create post | ['posts'] | Refetch all post lists, regardless of filters. |
| Update post 5 | ['posts', 5] | Refetch only post #5; leave the list alone. |
| Delete comment 3 | ['posts', postId, 'comments'] | Refetch all comments on postId. |
| Batch delete | ['posts'] | Invalidate the entire posts tree; all variants refetch. |
React Query's invalidateQueries uses prefix matching: passing ['posts'] invalidates ['posts'], ['posts', 5], and ['posts', { limit: 10 }] all at once.
// Example: Update a single post
const updatePostMutation = useMutation({
mutationFn: async (post) => {
const res = await fetch(`/api/posts/${post.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(post),
});
if (!res.ok) throw new Error('Update failed');
return res.json();
},
onSuccess: (updatedPost) => {
// Invalidate only this post, not the entire list
queryClient.invalidateQueries({ queryKey: ['posts', updatedPost.id] });
},
});
Combining invalidation with setQueryData
For instant UI updates without a full refetch, use setQueryData after a mutation. Then, optionally, invalidate in the background.
const createCommentMutation = useMutation({
mutationFn: async ({ postId, body }) => {
const res = await fetch(`/api/posts/${postId}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body }),
});
if (!res.ok) throw new Error('Failed to create comment');
return res.json();
},
onSuccess: (newComment, variables) => {
// Optimistically update the comments list in cache
queryClient.setQueryData(
['posts', variables.postId, 'comments'],
(oldComments) => {
if (!oldComments) return [newComment];
return [...oldComments, newComment];
}
);
// Then revalidate in background to confirm
queryClient.invalidateQueries({
queryKey: ['posts', variables.postId, 'comments'],
refetchType: 'stale', // Only refetch if the query is currently being used
});
},
});
This pattern combines instant feedback (the new comment appears immediately) with eventual consistency (a background refetch ensures the server's version is fetched if the component is still mounted).
Invalidation with Filtering
Use the exact and type options to refine which queries are invalidated.
// Invalidate only exact key match (not prefixes)
queryClient.invalidateQueries({
queryKey: ['posts'],
exact: true, // Only ['posts'], not ['posts', 5]
});
// Invalidate only "active" queries (those currently mounted and in use)
queryClient.invalidateQueries({
queryKey: ['posts'],
type: 'active', // Skip inactive queries
});
// Invalidate and refetch in the background (don't wait for it)
queryClient.invalidateQueries({
queryKey: ['posts'],
refetchType: 'inactive', // Only refetch queries not currently in use
});
Handling Dependent Invalidations
Some mutations affect multiple query keys. Organize invalidations to avoid cascading refetches.
const deleteCategoryMutation = useMutation({
mutationFn: async (categoryId) => {
const res = await fetch(`/api/categories/${categoryId}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Delete failed');
return res.json();
},
onSuccess: (data, categoryId) => {
// The category is deleted; invalidate:
// 1. The category itself
queryClient.invalidateQueries({ queryKey: ['categories', categoryId] });
// 2. The categories list
queryClient.invalidateQueries({ queryKey: ['categories'] });
// 3. Any posts in that category
queryClient.invalidateQueries({ queryKey: ['posts', { categoryId }] });
},
});
Batch Invalidations with Predicate Functions
For complex scenarios, use a predicate function to match multiple unrelated query keys.
// Invalidate ALL queries
queryClient.invalidateQueries();
// Invalidate all queries with a certain predicate
queryClient.invalidateQueries({
predicate: (query) => {
// Invalidate all queries that contain 'user' in their key
return query.queryKey.some((key) => typeof key === 'string' && key.includes('user'));
},
});
// Example: Logout — clear all user-related caches
const handleLogout = () => {
queryClient.clear(); // Nuclear option: clear everything
// Or more surgical:
queryClient.invalidateQueries({
predicate: (query) => {
const key = query.queryKey[0];
return ['profile', 'notifications', 'settings'].includes(key);
},
});
};
Key Takeaways
- Use
invalidateQueriesin mutationonSuccesscallbacks to mark stale data and trigger refetches. - Query key hierarchies enable granular invalidation: invalidate
['posts']for all variants, or['posts', 5]for a single post. - Combine
setQueryDatafor instant updates withinvalidateQueriesfor eventual consistency. - Use
refetchType: 'inactive'to refetch in the background without blocking the UI. - For complex invalidations, predicate functions match multiple unrelated query keys based on custom logic.
- Avoid over-invalidating (clearing too much cache at once); be surgical with key prefixes to minimize refetches.
Frequently Asked Questions
When should I use invalidateQueries vs setQueryData?
Use setQueryData for instant UI updates (optimistic patterns). Use invalidateQueries to refetch fresh data from the server. Often, you use both: setQueryData for immediate feedback, then invalidateQueries to confirm correctness.
Does invalidateQueries refetch immediately?
By default, yes—if the query is currently being used by a mounted component. Use refetchType: 'inactive' to refetch only if the component is not mounted, or set refetchType: 'none' to mark as stale without refetching.
What if I have a query key with nested filters, like ['posts', { tag: 'react', limit: 10 }]?
Invalidating ['posts'] will match it because React Query uses prefix matching. To invalidate only that exact filter combo, set exact: true.
Should I invalidate multiple keys or one broader key?
Invalidate at the level of the data change. If you update a post's title, invalidate ['posts', postId] alone. If you delete a category affecting many posts, invalidate ['posts'] to be safe.
Can I schedule invalidations to run later (e.g., after a delay)?
Yes, use setTimeout or a background job. But typically, invalidate immediately after onSuccess so users see fresh data as soon as possible.