TanStack Query Optimistic Updates: Instant Reflection with onMutate and Automatic Rollback with onError
This article is aimed at developers who are already familiar with the basic usage of TanStack Query's
useQueryanduseMutation.
Have you ever edited a cell in a data grid, only to have the screen freeze with no response for 0.5 seconds before finally updating? I ran into this myself when building my first admin dashboard. Technically there's nothing wrong with it, but when you actually use it, something feels slow and frustrating. From the user's perspective, there's a momentary confusion about whether their click was registered or not.
The pattern that solves this problem is Optimistic Update. It works by changing the UI immediately without waiting for a server response, then rolling back if something goes wrong. TanStack Query's onMutate + onError combination is the cleanest way to implement this pattern. With just a few lines of code added using this single pattern, you can instantly improve the perceived response time users experience.
Core Concepts
What Is an Optimistic Update?
True to its name, this is a pattern that "optimistically assumes success and updates first." Most user actions actually succeed. If the network is fine and the server is up, the chance of a simple like button failing is extremely low. So we assume success, change the UI first, and if it does fail, we revert at that point.
TanStack Query's three lifecycle callbacks naturally handle this flow.
| Callback | When It Runs | Role |
|---|---|---|
onMutate |
Before the mutation function runs | Cancel in-progress refetches → snapshot current state → immediately update cache → return context |
onError |
When the mutation fails | Restore the cache to its previous state using context (rollback) |
onSettled |
Both success and failure | Synchronize with server state via query invalidation |
context Is the Key to Rollback
Honestly, when I first encountered this pattern, I spent a long time wondering "why does onMutate return something?" That return value is the context.
context: The value returned by
onMutateis internally preserved by TanStack Query and automatically passed as the last argument toonErrorandonSettled. This lets you safely pass the previous data needed for rollback without any global state.
There's one more thing worth knowing: the type of context returned by onMutate is Context | undefined. This means the third argument received in onError can be undefined, which is why the example code below uses optional chaining like context?.snapshot. It guards against cases where an error occurred before onMutate ran, or where context was never returned.
const mutation = useMutation({
mutationFn: updateRowData,
onMutate: async (newData) => {
// Cancel in-progress refetches so they don't overwrite the optimistic update
await queryClient.cancelQueries({ queryKey: ['grid-data'] });
// Snapshot current state for rollback
const previousData = queryClient.getQueryData(['grid-data']);
// Immediately update the cache → the grid reflects it right away
queryClient.setQueryData(['grid-data'], (old: Row[] = []) =>
old.map((row) =>
row.id === newData.id ? { ...row, ...newData } : row
)
);
// Pass the previous state via context
return { previousData };
},
onError: (err, newData, context) => {
// Roll back the cache using context?.previousData (optional chaining because context may be undefined)
queryClient.setQueryData(['grid-data'], context?.previousData);
},
onSettled: () => {
// Final sync with server state regardless of success or failure
queryClient.invalidateQueries({ queryKey: ['grid-data'] });
},
});Why Is cancelQueries Necessary?
What happens if you leave out cancelQueries on the first line of onMutate? If a refetch was in progress in the background, that request could overwrite the optimistically updated cache with the old server data. The UI you immediately updated would revert back to its original state 0.3 seconds later. cancelQueries prevents this scenario.
Note:
cancelQueriesis an asynchronous function. If you omitawait, the cache update may execute before the cancellation has completed.
TanStack Query v5: Two Coexisting Patterns
Starting with v5, in addition to the cache-based approach (onMutate), a UI variable-based approach is also officially supported. It requires far less code, but has a more limited scope of application.
// v5 variable-based optimistic update
const { mutate, isPending, variables } = useMutation({ mutationFn: updateItem });
const displayData = isPending
? data.map((item) =>
item.id === variables.id ? { ...item, ...variables } : item
)
: data;| Pattern | Best For | Rollback Needed? |
|---|---|---|
onMutate cache-based |
Grids and dashboards where multiple components share the same data | Yes (handled via onError) |
Variable-based (variables) |
Simple toggles or like buttons within a single component | No (resolves automatically when mutation completes) |
Practical Application
Now let's apply these concepts to real code.
Example 1: Data Grid Inline Editing
This is the situation you encounter most often in practice. In an admin table, clicking a cell to edit it reflects instantly, and if saving fails, only that row reverts to its original value.
import { useMutation, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
// Row type: { id: string; [key: string]: unknown }
function useGridRowUpdate(tableId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (updatedRow: Row) => api.updateRow(tableId, updatedRow),
onMutate: async (updatedRow) => {
await queryClient.cancelQueries({ queryKey: ['rows', tableId] });
const snapshot = queryClient.getQueryData<Row[]>(['rows', tableId]);
queryClient.setQueryData<Row[]>(['rows', tableId], (rows = []) =>
rows.map((r) => (r.id === updatedRow.id ? updatedRow : r))
);
return { snapshot };
},
onError: (_err, _updatedRow, context) => {
// Use optional chaining because context may be undefined
queryClient.setQueryData(['rows', tableId], context?.snapshot);
toast.error('Save failed. Your changes have been reverted.');
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['rows', tableId] });
},
});
}| Code Section | Role |
|---|---|
cancelQueries |
Prevents in-progress data fetching from overwriting the optimistic state |
getQueryData + snapshot |
Preserves previous data to use for rollback |
setQueryData |
Directly updates the cache so the grid reflects changes immediately |
toast.error |
Provides clear feedback to the user when a rollback occurs |
invalidateQueries |
Final sync with the latest server state |
Example 2: Social Like Button
This is a case that needs a bit more nuance. You have to update both the count and the toggle state simultaneously. If it's only displayed in a single component, v5's variable-based approach is simpler, but if the like count for the same post is shown in multiple places, the cache-based approach is more appropriate.
// Post type: { id: string; liked: boolean; likeCount: number; [key: string]: unknown }
function useLikePost() {
const queryClient = useQueryClient();
return useMutation({
// isCurrentlyLiked: whether the like is currently active (not the intended state after clicking)
mutationFn: ({ postId, isCurrentlyLiked }: { postId: string; isCurrentlyLiked: boolean }) =>
isCurrentlyLiked ? api.unlikePost(postId) : api.likePost(postId),
onMutate: async ({ postId, isCurrentlyLiked }) => {
await queryClient.cancelQueries({ queryKey: ['post', postId] });
const previousPost = queryClient.getQueryData<Post>(['post', postId]);
queryClient.setQueryData<Post>(['post', postId], (old) => {
if (!old) return old;
return {
...old,
liked: !isCurrentlyLiked,
likeCount: isCurrentlyLiked ? old.likeCount - 1 : old.likeCount + 1,
};
});
return { previousPost };
},
onError: (_err, { postId }, context) => {
queryClient.setQueryData(['post', postId], context?.previousPost);
},
onSettled: (_data, _err, { postId }) => {
queryClient.invalidateQueries({ queryKey: ['post', postId] });
},
});
}Example 3: Drag & Drop Reordering (A Case Requiring Careful Design)
You can apply optimistic updates to drag-and-drop reordering as well. However, if the server imposes constraints on ordering (e.g., priority rules), or if multiple users can change the order simultaneously, rollbacks can cause confusion, so careful design is required.
// Excerpt of onMutate only — onError and onSettled follow the same structure as previous examples
onMutate: async ({ draggedId, targetIndex }: { draggedId: string; targetIndex: number }) => {
await queryClient.cancelQueries({ queryKey: ['items'] });
const previousItems = queryClient.getQueryData<Item[]>(['items']);
queryClient.setQueryData<Item[]>(['items'], (old = []) => {
const reordered = [...old];
const draggedIndex = reordered.findIndex((i) => i.id === draggedId);
const [removed] = reordered.splice(draggedIndex, 1);
reordered.splice(targetIndex, 0, removed);
return reordered;
});
return { previousItems };
},Pros and Cons Analysis
After looking at all three examples, you can probably feel how powerful this pattern is. So when should you not use it?
Pros
| Item | Details |
|---|---|
| Improved perceived speed | Immediate UI feedback can improve perceived response time by up to 40% |
| UX continuity | Interactions don't feel interrupted even with network latency |
| Cache consistency | setQueryData can simultaneously reflect changes across multiple components |
| Automated rollback | The context pattern enables rollback without any separate global state |
| Code cohesion | Optimistic update logic is centralized in one mutation |
Cons and Caveats
| Item | Details | Mitigation |
|---|---|---|
| Client-server mismatch | The cache temporarily differs from the actual server state | Always sync with invalidateQueries in onSettled |
| Race conditions | Rapid consecutive actions can cause multiple mutations to conflict | Apply mutation serialization or debouncing |
| Duplicated server logic | The client must predict the server's data transformation results | Design the API to minimize logic divergence between server and client |
| Rollback UX design | A silent rollback is a jarring experience for users | Always include Toast/Alert feedback in onError |
Race condition: A phenomenon where unpredictable results occur when multiple asynchronous operations proceed without order guarantees. There are cases that
cancelQueriesalone cannot fully resolve, and this is actively discussed in TanStack Query GitHub Discussion #7932. The current conclusion from that discussion is that "mutation serialization or debouncing is the most practical solution," while an official library-level fix is still under discussion.
Mutation serialization: A technique that ensures multiple mutations don't execute simultaneously by guaranteeing their order. This can be applied by using button disabling or the
isPendingstate to only allow the next request after the previous one has completed.
The Most Common Mistakes in Practice
I missed these myself at first and spent a long time debugging — these are the mistakes you actually see most often in real-world code.
- Calling
cancelQuerieswithoutawait— If you omitawait, the cache update executes before the cancellation is complete, allowing a subsequently resolved refetch to overwrite the optimistic state. Until you experience it yourself, the symptoms are so intermittent that the root cause is very hard to track down. - Omitting user feedback after a rollback — If the screen suddenly reverts to its previous state with no message, users will either think it's a bug or be confused that their action was ignored.
onErrormust always be accompanied by clear feedback. - Applying optimistic updates to delete operations or payment transactions — It's very jarring when an item disappears from a list immediately and then reappears after a failure. The same applies to payment and financial transactions, and to operations with a high failure rate — optimistic updates can actually do more harm than good here. In these cases, it's far better to wait for the server response.
Closing Thoughts
After applying this pattern in production, the biggest change wasn't fewer user complaints — it was my own confidence in my code. With rollback automated, I could boldly move the UI first without the anxiety of "what if it fails?" Take a snapshot in onMutate, pass it via context, and onError will safely restore the previous state at any time — no global state required.
Here are 3 steps you can start with right now:
- Pick one mutation in your current project that users click often and add an
onMutatecallback. The order is:await queryClient.cancelQueries(...)→ snapshot withgetQueryData→ immediate update withsetQueryData. - Connect the rollback in
onErrorwith a single line:queryClient.setQueryData(key, context?.snapshot), then force a request failure in the Network tab to verify the rollback works. - If it's a simple toggle that only appears in a single component, you can achieve the same effect with less code using v5's
variables-based approach. On the other hand, if multiple components are displaying the same data simultaneously, or if you need clear user feedback after a rollback, theonMutatecache-based approach is more suitable. Comparing the two patterns directly will naturally sharpen your judgment for when to use each.
References
- Optimistic Updates | TanStack Query v5 Official Docs
- Optimistic Updates Cache Example | TanStack Query v5
- Optimistic Updates UI Example | TanStack Query v5
- Mutations | TanStack DB Official Docs
- TanStack DB Beta Release | InfoQ
- Concurrent Optimistic Updates in React Query | Tkdodo's Blog
- Optimistic Updates in TanStack Query v5 | Medium
- Building a Data Table with Optimistic Updates | DEV Community
- Automatic Rollback with Normalized Data | DEV Community
- Why I Never Use Optimistic Updates | DEV Community
- How Optimistic Updates Make Apps Feel Faster | OpenReplay Blog
- How to Use useOptimistic Hook in React | FreeCodeCamp
- Race Condition with cancelQueries | GitHub Discussion