Fixing React Query Stale Data Showing After a Successful Mutation
You submit a form, the mutation resolves with a 200, but the list on screen still shows the old record. You refresh and everything is correct. That gap between server truth and UI state is a React Query cache problem, and it has a handful of well-defined solutions.
This article walks through the most common causes and the exact fixes. No hand-waving β you'll see real code patterns you can drop straight into your project.
What you'll learn
- Why React Query holds onto stale data even after a successful mutation
- How to use
invalidateQueriesto trigger a background refetch - How to write directly to the cache for an instant UI update
- How to implement optimistic updates safely, including rollback on error
- Common mistakes that silently break cache invalidation
Prerequisites
These examples use TanStack Query v5 (the package formerly known as React Query). The patterns are almost identical in v4, but the useMutation callback signatures differ slightly. You should be comfortable with basic useQuery and useMutation hooks before continuing.
Why Stale Data Appears in the First Place
React Query is a client-side cache. When you call useQuery, it stores the result under a query key. By default, that cached value is considered stale immediately after it is fetched β but stale does not mean gone. React Query still serves the stale value from the cache while it decides whether to refetch.
A mutation knows nothing about your queries unless you explicitly connect them. When useMutation succeeds, React Query does not automatically know which cached queries are now invalid. That is your job. Until you tell it otherwise, the cache happily serves the old data.
This is a deliberate design choice, not a bug. It keeps the library flexible. Your job is to bridge the gap.
The Simplest Fix: invalidateQueries
The fastest way to synchronise the cache after a mutation is to call queryClient.invalidateQueries inside the onSuccess callback. This marks the matching queries as stale and triggers a background refetch immediately.
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { updateTodo } from './api';
export function useUpdateTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateTodo,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
}The key ['todos'] acts as a prefix. Any query whose key starts with 'todos' β including ['todos', 1], ['todos', 'active'] β will be invalidated. This is usually what you want: invalidate broadly rather than surgically, unless you have a performance reason to be specific.
Exact key matching when you need it
If you only want to invalidate a single specific query, add exact: true.
queryClient.invalidateQueries({
queryKey: ['todos', todoId],
exact: true,
});Use exact matching sparingly. If you add a new list query later and forget to update the invalidation, you will recreate the same stale-data bug.
Writing Directly to the Cache with setQueryData
Calling invalidateQueries triggers a network request. If you already have the updated data in the mutation response, you can skip the round trip and write directly into the cache with setQueryData.
return useMutation({
mutationFn: updateTodo,
onSuccess: (updatedTodo) => {
// Update a single item query
queryClient.setQueryData(['todos', updatedTodo.id], updatedTodo);
// Also invalidate the list so it re-fetches fresh order/filters
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});This pattern gives you the best of both worlds: the detail view updates instantly from the mutation response, and the list refetches in the background. The user sees no flash of old content.
Optimistic Updates: Update the UI Before the Server Responds
For interactions that feel slow β toggling a checkbox, reordering a list β you can update the cache before the mutation resolves and roll back if it fails. React Query calls this an optimistic update.
return useMutation({
mutationFn: toggleTodo,
onMutate: async (toggledTodo) => {
// Cancel any in-flight refetches so they don't overwrite our optimistic state
await queryClient.cancelQueries({ queryKey: ['todos'] });
// Snapshot the current value so we can roll back on error
const previousTodos = queryClient.getQueryData(['todos']);
// Optimistically update the cache
queryClient.setQueryData(['todos'], (old) =>
old.map((todo) =>
todo.id === toggledTodo.id
? { ...todo, completed: !todo.completed }
: todo
)
);
// Return context for the onError callback
return { previousTodos };
},
onError: (err, toggledTodo, context) => {
// Roll back to the snapshot
queryClient.setQueryData(['todos'], context.previousTodos);
},
onSettled: () => {
// Always refetch to make sure we're in sync
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});The onMutate callback runs synchronously before the network request. Returning the previous data as context passes it through to onError, where you use it to restore the cache if the server rejects the change. The onSettled hook fires whether the mutation succeeded or failed, giving you a clean place to do a final sync.
Common Pitfalls That Break Cache Invalidation
Query key mismatch
This is the most common cause of invalidation silently doing nothing. If your useQuery key is ['todos', { status: 'active' }] but you invalidate ['todos'] with exact: true, nothing happens. Always double-check that the key you pass to invalidateQueries matches or is a prefix of the key used in the query.
Using a new queryClient instance in the mutation
Calling useQueryClient() inside a utility function that runs outside a React component will return a different instance if you have not set up the provider correctly. Always get the client from the hook inside a component or a custom hook, never by importing a bare new QueryClient().
Forgetting async/await in onMutate
If you skip the await on cancelQueries inside onMutate, an in-flight refetch can overwrite your optimistic state before the mutation completes. Always await it.
// Wrong β missing await
onMutate: (todo) => {
queryClient.cancelQueries({ queryKey: ['todos'] }); // fire and forget
queryClient.setQueryData(['todos'], ...);
},
// Correct
onMutate: async (todo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
queryClient.setQueryData(['todos'], ...);
},staleTime set too high
If you have configured a long staleTime β say, five minutes β and you invalidate a query, React Query will only refetch if the query has an active observer (a mounted component using useQuery). If the component is not mounted, the data will be refreshed next time it mounts. That is usually fine, but be aware of it when debugging.
Choosing the Right Strategy
| Situation | Recommended approach |
|---|---|
| Mutation response does not include updated data | invalidateQueries only |
| Mutation response includes the full updated resource | setQueryData + invalidateQueries for the list |
| Interaction must feel instant (toggle, drag, like) | Optimistic update with rollback |
| Multiple unrelated queries need refreshing | Multiple invalidateQueries calls in onSettled |
Start with invalidateQueries. It is safe, predictable, and covers 80% of cases. Reach for setQueryData or optimistic updates only when you have a measurable UX reason.
Structuring Mutations at Scale
Once you have more than a handful of mutations, keeping the cache logic consistent gets tricky. A clean pattern is to colocate each mutation in its own custom hook and keep all invalidation logic inside that hook.
// hooks/usCreateTodo.js
export function useCreateTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createTodo,
onSuccess: (newTodo) => {
queryClient.setQueryData(['todos', newTodo.id], newTodo);
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
}
// hooks/useDeleteTodo.js
export function useDeleteTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteTodo,
onSuccess: (_, deletedId) => {
queryClient.removeQueries({ queryKey: ['todos', deletedId] });
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
}Each hook owns its side effects. When something breaks, you know exactly where to look.
Wrapping Up
Stale data after a mutation is almost always a missing or mismatched invalidation. Here are the concrete steps to take from here:
- Audit your query keys. Log them with
queryClient.getQueryCache().getAll().map(q => q.queryKey)and confirm your invalidation keys match. - Start with
invalidateQueriesinonSuccessfor every mutation that changes server state. Add complexity only if you need it. - Use
setQueryDatawhen your mutation response already contains the updated resource to avoid an unnecessary round trip. - Add the React Query DevTools (
@tanstack/react-query-devtools) to your dev build. You can watch cache entries update in real time and spot stale keys instantly. - Write one custom hook per mutation and keep all cache logic inside it. This makes debugging and refactoring much easier as your app grows.
π€ Share this article
Sign in to saveRelated Articles
Comments (0)
No comments yet. Be the first!