Optimistic updates, rollback, and targeted cache invalidation instead of blanket refetching
A POST request is quick to fire off, the real work starts afterwards: updating the cache, invalidating affected queries, cleanly rolling back on failure. useMutation offers a clear API pattern for this instead of scattered handler logic.
Table of Contents
- 1. useQuery Reads, useMutation Writes
- 2. The Basics of useMutation
- 3. Implementing Optimistic Updates
- 4. Rolling Back Failed Mutations
- 5. Targeted Cache Invalidation
- 6. The Difference from Manual Refetching
- 7. Ordering for Rapid Consecutive Mutations
- 8. Optimistic Updates for Lists
- 9. Common Mistakes with Mutations
- 10. Summary
- 11. FAQ
1. useQuery Reads, useMutation Writes
useQuery is responsible for reading server state and keeps the results in a central cache that multiple components can subscribe to at once. useMutation handles the complementary case, changing that state through POST, PUT, PATCH, or DELETE requests, and is the intended place to trigger side effects such as cache updates after a successful change.
Unlike a manual approach with fetch and local useState, useMutation encapsulates status information such as isPending, isError, and isSuccess, built-in retry logic, and a fixed sequence of callback hooks that mesh seamlessly with the same query cache useQuery uses.
2. The Basics of useMutation
useMutation({ mutationFn, onMutate, onError, onSuccess, onSettled }) takes the actual request function plus a set of optional callbacks. mutate() fires the mutation fire-and-forget style, mutateAsync() additionally returns a promise in case the calling code needs to await the result directly. onMutate runs synchronously before the actual request and is the right place for optimistic updates.
onSettled always runs, regardless of whether the mutation succeeded or failed, which makes it a good central place for final invalidateQueries calls. That way the cache stays back in sync with the actual server state in both cases, while onSuccess and onError each only handle the matching outcome.
import { useMutation, useQueryClient } from '@tanstack/react-query';
function useUpdateTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (todo) => api.updateTodo(todo.id, todo),
onSuccess: () => {
console.log('Todo updated successfully');
},
onError: (error) => {
console.error('Mutation failed', error);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
}
3. Implementing Optimistic Updates
With an optimistic update, the UI is updated immediately, before the server's response even arrives. In onMutate, that's done by writing directly to the cache via queryClient.setQueryData, with exactly the value the UI should show once the mutation succeeds.
Before the cache is written optimistically, queryClient.cancelQueries has to be called for the same query key. Skip that step, and a still-running background refetch can overwrite the optimistic update while the mutation is still in flight, causing a brief, confusing snap-back in the display.
function useUpdateTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (todo) => api.updateTodo(todo.id, todo),
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos', newTodo.id] });
const previous = queryClient.getQueryData(['todos', newTodo.id]);
queryClient.setQueryData(['todos', newTodo.id], newTodo);
return { previous };
},
});
}
4. Rolling Back Failed Mutations
onMutate can return a context value, usually a snapshot of the old cache value taken before the optimistic write. That context is automatically passed as the third argument to onError, letting it reset the cache with setQueryData back to exactly the state before the mutation.
Without this snapshot mechanism, a failed request would leave an optimistic state, never confirmed by the server, permanently visible in the UI. The combination of a snapshot in onMutate and a rollback in onError is therefore not optional polish but a necessary requirement as soon as optimistic updates are used at all.
function useUpdateTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (todo) => api.updateTodo(todo.id, todo),
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos', newTodo.id] });
const previous = queryClient.getQueryData(['todos', newTodo.id]);
queryClient.setQueryData(['todos', newTodo.id], newTodo);
return { previous };
},
onError: (error, newTodo, context) => {
// Roll back to the state before the optimistic change
queryClient.setQueryData(['todos', newTodo.id], context.previous);
},
});
}
5. Targeted Cache Invalidation
invalidateQueries({ queryKey }) marks all matching queries as stale and automatically refetches actively subscribed ones. A granular query key structure is key to precise invalidation, for example ['todos', 'list', { filters }] instead of a blanket ['todos'] that matches everything under that prefix.
Overly broad invalidation triggers unnecessarily many simultaneous refetches, straining the network and the server without real benefit. Overly narrow invalidation, on the other hand, misses dependent queries, for example a detail view of a todo that was just deleted from the list, whose detail query is now stale too but stays untouched.
// Only matches list queries with the matching prefix,
// not the individual detail queries per todo id
queryClient.invalidateQueries({ queryKey: ['todos', 'list'] });
// Matches every query whose key starts with 'todos',
// including both list and detail queries
queryClient.invalidateQueries({ queryKey: ['todos'] });
// Predicate based invalidation for more complex cases
queryClient.invalidateQueries({
predicate: (query) =>
query.queryKey[0] === 'todos' && query.queryKey[1] === 'list',
});
6. The Difference from Manual Refetching
A hand-rolled pattern where a fetch call in an event handler is followed by another fetch in a useEffect, or a full page reload, only ever synchronizes the one component that contains that code. Other components showing the same server state, say a counter in the navigation bar, stay stale until they reload on their own.
useMutation combined with invalidateQueries, by contrast, automatically synchronizes every component subscribed to the same query key, regardless of where in the component tree the mutation was triggered. That is the real structural benefit over hand-rolled fetch logic, not just less code, but consistent state across the entire application.
7. Ordering for Rapid Consecutive Mutations
Rapid consecutive mutations, say an accidental double click on a like button, can arrive at the server out of order depending on network conditions. mutationKey together with the networkMode and retry options helps keep retry behavior and network behavior consistent for a group of related mutations.
For strictly sequential mutations where order matters for correctness, a dedicated queue is worth building, for example an async function that processes mutations one after another via await mutateAsync(). For the simpler, but very common case, it's usually enough to disable the triggering button while isPending is true, preventing double submits in the first place.
function LikeButton({ postId }) {
const { mutate, isPending } = useMutation({
mutationFn: () => api.likePost(postId),
});
return (
<button disabled={isPending} onClick={() => mutate()}>
{isPending ? 'Sending...' : 'Like'}
</button>
);
}
8. Optimistic Updates for Lists
For lists like a todo list or comments, the optimistic update doesn't affect a single value but an entire array in the cache. A new item is inserted via setQueryData((old) => [...old, tempItem]), a deleted item removed from the existing array via filter accordingly, both inside onMutate.
For a newly inserted item, a client-generated temporary id, say via crypto.randomUUID(), is worth using while the real, server-assigned id isn't known yet. After a successful response, the temporary item is either replaced by the real server object in onSuccess, or the affected query key is fully reloaded via invalidateQueries.
function useAddComment(postId) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (text) => api.addComment(postId, text),
onMutate: async (text) => {
await queryClient.cancelQueries({ queryKey: ['comments', postId] });
const previous = queryClient.getQueryData(['comments', postId]);
const tempItem = { id: crypto.randomUUID(), text, pending: true };
queryClient.setQueryData(['comments', postId], (old = []) => [...old, tempItem]);
return { previous };
},
onError: (err, text, context) => {
queryClient.setQueryData(['comments', postId], context.previous);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['comments', postId] });
},
});
}
9. Common Mistakes with Mutations
The most common mistake is an optimistic update in onMutate without a matching rollback in onError. If the request fails, the optimistically written state, never confirmed by the server, stays permanently visible in the UI, which is especially confusing for deleted or changed entries.
A second common mistake is a wrong or overly specific query key in invalidateQueries, so related queries such as a detail page, a counter, or a filtered list don't get updated too. On top of that, missing cancelQueries before the optimistic write is common, letting a concurrent background refetch silently overwrite the optimistic update.
| Strategy | UI Response Time | Error Handling | When to Use |
|---|---|---|---|
| Manual refetch after mutation | Delayed until server response plus another request | Manual in the handler, often inconsistent | Only for very simple prototypes |
| useMutation without optimistic update | Delayed until server response | Handled centrally via onError | When UI latency is acceptable |
| useMutation with optimistic update and rollback | Immediate, before the server response | Automatic rollback via snapshot | For frequent interactions like likes, toggles |
| useMutation with targeted invalidateQueries | Immediately visible after successful mutation | Consistent across all subscribed components | For changes affecting multiple views |
Mironsoft
React architecture, performance, and Magento frontend integration
React frontends that stay fast instead of slowing down with every feature?
We review existing React applications for unnecessary re-renders, bloated bundles, and fragile state management, then build a frontend that stays performant and connects cleanly to Magento or other backends.
Performance Audit
Systematically measuring and fixing re-renders, bundle size, and load times.
State Architecture
Cleanly separating context, client state, and server state instead of mixing everything.
Magento Integration
Building robust, type-safe GraphQL or REST integration with Magento.
10. Summary
TanStack Query Mutations: The Essentials at a Glance
onMutate
Runs synchronously before the request, the place for optimistic setQueryData and a snapshot of the old value.
onError
Receives the snapshot from onMutate as context and uses it to roll back to the previous state.
onSettled
Always runs regardless of outcome, the right place for final invalidateQueries calls.
Query Keys
A granular query key structure enables precise invalidation instead of blanket, expensive refetching.