The problem: waiting for the server feels slow
Every time a user performs an action—liking a post, adding an item to a cart, updating a profile—your app traditionally waits for the server to respond before showing the change. That round trip adds latency. Users see spinners. They wonder if their action registered. In competitive products, that friction costs engagement.
Optimistic updates: a React & Next.js guide for developers addresses this by flipping the sequence: show the change immediately, then confirm it with the server. If the server rejects the operation, roll back. This pattern transforms perceived performance and user confidence, especially on high-latency networks.
Snapshot current cache data
Before you mutate anything, capture the exact current state. This snapshot becomes your undo point if the server request fails. You need both the data structure and enough context to restore it cleanly.
In a typical React app using TanStack Query (React Query), you'll use the setQueryData method to stage changes. The snapshot gives you a rollback target.
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
export function useLikePost(postId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (liked: boolean) =>
fetch(`/api/posts/${postId}/like`, {
method: 'POST',
body: JSON.stringify({ liked }),
}).then((res) => res.json()),
onMutate: async (liked: boolean) => {
// Snapshot the current posts list
await queryClient.cancelQueries({ queryKey: ['posts'] });
const previousPosts = queryClient.getQueryData(['posts']);
// Apply optimistic update
queryClient.setQueryData(['posts'], (old: Post[]) =>
old.map((post) =>
post.id === postId
? { ...post, liked, likeCount: liked ? post.likeCount + 1 : post.likeCount - 1 }
: post
)
);
return { previousPosts };
},
});
}
Notice the onMutate hook runs before the server request. It captures previousPosts and returns it as context. That context object gets passed to error and success handlers so you can restore or act on it later.
If you're not using TanStack Query, store the snapshot in a ref or local state. The principle remains: preserve the old data before you touch the new.
Apply the change immediately
Once you have the snapshot, update your UI's data layer synchronously. The user sees the change instantly. From their perspective, the operation succeeded.
In the example above, queryClient.setQueryData updates the React Query cache, which automatically re-renders components watching that query. The like count increments, the heart icon fills in, all before the fetch completes.
The key is that this update is immediate and local. No network call blocks it. Here's a more complete example with form submission:
export function CartItem({ item }: { item: CartItemType }) {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (quantity: number) =>
fetch(`/api/cart/${item.id}`, {
method: 'PATCH',
body: JSON.stringify({ quantity }),
}).then((res) => res.json()),
onMutate: async (newQuantity: number) => {
await queryClient.cancelQueries({ queryKey: ['cart'] });
const previousCart = queryClient.getQueryData(['cart']);
queryClient.setQueryData(['cart'], (old: CartType) => ({
...old,
items: old.items.map((i) =>
i.id === item.id ? { ...i, quantity: newQuantity } : i
),
total: old.total + (newQuantity - item.quantity) * item.price,
}));
return { previousCart };
},
onError: (_, __, context) => {
if (context?.previousCart) {
queryClient.setQueryData(['cart'], context.previousCart);
}
},
});
return (
<div>
<button onClick={() => mutation.mutate(item.quantity - 1)}>−</button>
<span>{item.quantity}</span>
<button onClick={() => mutation.mutate(item.quantity + 1)}>+</button>
</div>
);
}
When the user clicks the increment button, the quantity and total update immediately. The spinner might still be visible, but the numbers are already correct. That's the optimistic part.
Roll back on error
The server might reject the request: validation fails, business rules prevent it, or the resource no longer exists. Your error handler must restore the snapshot.
In TanStack Query, the onError callback receives the context object you returned from onMutate. Use it to revert the cache:
onError: (error, variables, context) => {
if (context?.previousCart) {
queryClient.setQueryData(['cart'], context.previousCart);
}
// Show the user what went wrong
toast.error(`Failed to update cart: ${error.message}`);
};
The UI springs back to the correct state. The user sees an error message explaining why their action didn't stick. No silent failures, no confusion.
Consider which errors warrant a rollback. A 4xx response (bad request, conflict) almost always does. A 5xx response might too, unless you want to retry. A network timeout might trigger a retry instead of an immediate rollback.
onError: (error, variables, context) => {
// Only rollback on client or server error, not network issues
if (error.status !== 'NETWORK_ERROR') {
if (context?.previousCart) {
queryClient.setQueryData(['cart'], context.previousCart);
}
}
toast.error(error.message);
};
Refetch to reconcile with the server
After the mutation succeeds, your optimistic update is probably correct—but not guaranteed. The server might have computed additional fields, applied default values, or enforced constraints you didn't anticipate. Refetch the affected data to reconcile.
onSuccess: (data, variables) => {
// Server returned the final state; use it
queryClient.setQueryData(['cart'], data);
// Or refetch to ensure we're in sync
queryClient.invalidateQueries({ queryKey: ['cart'] });
toast.success('Cart updated');
};
The invalidateQueries approach marks the cache as stale and triggers a refetch. This is safer if you want the single source of truth to always be the server. The setQueryData approach is faster if the server response contains the full final state and you trust your optimistic logic.
In practice, use setQueryData when the server returns the mutated resource, and invalidateQueries when you need to refetch related data or you're uncertain about server-side side effects.
Common mistakes
- Not snapshotting before mutating. If you forget this step, you have no rollback point. Your UI will be inconsistent if the mutation fails.
- Snapshotting the wrong data. Make sure you're capturing the exact query or state atom you're about to change. If you snapshot the wrong variable, the rollback will be useless.
- Applying the update to UI state instead of cache. If you update local component state for the optimistic change and cache state differently, they'll diverge. Pick one source of truth.
- Forgetting to cancel in-flight queries. If a refetch is already running, calling
cancelQueriesstops it so your optimistic update takes precedence. Without this, the old server data can overwrite your optimistic change. - Over-invalidating. Invalidating too many queries forces unnecessary refetches and negates the performance benefit of optimistic updates. Invalidate only what's truly affected.
- Not communicating failures to the user. A silent rollback confuses users. Always surface error messages so they know why their action didn't persist.
Key takeaways
- Optimistic updates improve perceived performance by showing changes immediately, before server confirmation.
- Always snapshot the current cache state before mutating, so you have a rollback target.
- Apply the update to your data layer (React Query cache, Redux state, context) synchronously in the
onMutatehandler. - On error, restore the snapshot in the
onErrorhandler and show the user a clear error message. - After success, either set the server response as canonical or invalidate the query to refetch and reconcile.
- Cancel in-flight queries before applying optimistic updates to prevent race conditions.
- Use this pattern for all user-initiated mutations: likes, comments, cart changes, profile edits, and other transactional operations.






Comments
Loading comments…