Optimistic Updates Explained: The UI Fast-Track
Optimistic updates are a UX superpower. Instead of waiting for the server to respond, you update the UI immediately—assuming success. If the server confirms the write, you're done. If it fails, you roll back the UI and show an error. This pattern is invisible when networks are fast and errors are rare, but users feel the difference on 4G and international connections. Facebook, Gmail, and Figma all use optimistic updates for sub-100ms perceived latency on mutations.
How Optimistic Updates Work
The flow is simple: (1) user acts, (2) update cache instantly, (3) send request to server, (4) confirm or rollback based on response.
import { useMutation, useQueryClient } from '@tanstack/react-query';
export default function LikeButton({ postId, isLiked, likeCount }) {
const queryClient = useQueryClient();
const likeMutation = useMutation({
mutationFn: async () => {
const res = await fetch(`/api/posts/${postId}/like`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: isLiked ? 'unlike' : 'like' }),
});
if (!res.ok) throw new Error('Like failed');
return res.json();
},
// Save the old data in case we need to rollback
onMutate: async () => {
// Cancel pending refetches so they don't overwrite our optimistic update
await queryClient.cancelQueries({ queryKey: ['posts', postId] });
// Snapshot the current cache
const previousPost = queryClient.getQueryData(['posts', postId]);
// Optimistically update the cache
queryClient.setQueryData(['posts', postId], (old) => ({
...old,
isLiked: !old.isLiked,
likeCount: old.isLiked ? old.likeCount - 1 : old.likeCount + 1,
}));
return { previousPost }; // Context to use in onError
},
onError: (err, variables, context) => {
// Rollback: restore the previous data
if (context?.previousPost) {
queryClient.setQueryData(['posts', postId], context.previousPost);
}
},
onSuccess: () => {
// Server confirmed; optionally refetch to ensure sync
queryClient.invalidateQueries({ queryKey: ['posts', postId] });
},
});
return (
<button onClick={() => likeMutation.mutate()}>
{likeMutation.isPending ? 'Loading...' : `${isLiked ? '❤️' : '🤍'} Like (${likeCount})`}
</button>
);
}
Key points:
onMutateruns before the request; update the cache and save a snapshot.onErrorreceives the snapshot via thecontextparameter; use it to rollback.onSuccessconfirms the mutation; optionally refetch or invalidate.- The UI updates instantly (imperceptible to users) while the request is in-flight.
Advanced Optimistic Updates with Variables
When you need the new data in your optimistic update, use the variables parameter passed to onMutate.
const addCommentMutation = 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('Comment failed');
return res.json();
},
onMutate: async (variables) => {
const { postId, body } = variables;
// Cancel refetches
await queryClient.cancelQueries({ queryKey: ['posts', postId, 'comments'] });
// Snapshot old data
const previousComments = queryClient.getQueryData(['posts', postId, 'comments']);
// Create optimistic comment object
const optimisticComment = {
id: `temp-${Date.now()}`, // Temporary ID until server responds
postId,
body,
author: 'You',
createdAt: new Date().toISOString(),
};
// Optimistically append to comments list
queryClient.setQueryData(['posts', postId, 'comments'], (old) => {
if (!old) return [optimisticComment];
return [...old, optimisticComment];
});
return { previousComments };
},
onError: (err, variables, context) => {
if (context?.previousComments) {
queryClient.setQueryData(
['posts', variables.postId, 'comments'],
context.previousComments
);
}
},
onSuccess: (newComment, variables) => {
// Replace temporary comment with server version
queryClient.setQueryData(['posts', variables.postId, 'comments'], (old) => {
if (!old) return [newComment];
return old.map((c) => (c.id.startsWith('temp-') ? newComment : c));
});
},
});
const handleAddComment = (body) => {
addCommentMutation.mutate({ postId: 123, body });
};
Notice the temporary ID (temp-${Date.now()}): it lets you distinguish optimistic comments from confirmed ones. On success, replace the optimistic entry with the server's version (which has a real ID, timestamps, etc.).
UI Indicators for Optimistic Data
Show visual feedback that a piece of data is pending confirmation. Use a subtle opacity or spinner.
export default function CommentList({ postId }) {
const { data: comments = [] } = useQuery({
queryKey: ['posts', postId, 'comments'],
queryFn: async () => {
const res = await fetch(`/api/posts/${postId}/comments`);
if (!res.ok) throw new Error('Fetch failed');
return res.json();
},
});
return (
<div>
{comments.map((comment) => (
<div
key={comment.id}
style={{
opacity: comment.id.startsWith('temp-') ? 0.6 : 1,
transition: 'opacity 0.3s ease',
}}
>
<p>{comment.body}</p>
<small>
{comment.id.startsWith('temp-') && <span>Posting...</span>}
{!comment.id.startsWith('temp-') && <span>{new Date(comment.createdAt).toLocaleString()}</span>}
</small>
</div>
))}
</div>
);
}
Handling Optimistic Failures Gracefully
When a rollback occurs, the user expects clear feedback. Don't silently restore old data; explain what happened.
const deleteTodoMutation = useMutation({
mutationFn: async (todoId) => {
const res = await fetch(`/api/todos/${todoId}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Delete failed');
return res.json();
},
onMutate: async (todoId) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previousTodos = queryClient.getQueryData(['todos']);
queryClient.setQueryData(['todos'], (old) => old.filter((t) => t.id !== todoId));
return { previousTodos, todoId };
},
onError: (err, todoId, context) => {
// Rollback with explanation
if (context?.previousTodos) {
queryClient.setQueryData(['todos'], context.previousTodos);
}
console.error(`Failed to delete todo #${todoId}:`, err.message);
// Show a toast or inline error message to the user
},
});
const handleDelete = (todoId) => {
deleteTodoMutation.mutate(todoId);
};
Optimistic Updates with Nested Data
For deep cache updates (e.g., updating a nested author's name), use immutable update patterns.
const updateAuthorMutation = useMutation({
mutationFn: async ({ postId, authorName }) => {
const res = await fetch(`/api/posts/${postId}/author`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: authorName }),
});
if (!res.ok) throw new Error('Update failed');
return res.json();
},
onMutate: async (variables) => {
const { postId, authorName } = variables;
await queryClient.cancelQueries({ queryKey: ['posts', postId] });
const previousPost = queryClient.getQueryData(['posts', postId]);
// Deep update: preserve all other fields, update author.name
queryClient.setQueryData(['posts', postId], (old) => ({
...old,
author: { ...old.author, name: authorName },
}));
return { previousPost };
},
onError: (err, variables, context) => {
if (context?.previousPost) {
queryClient.setQueryData(['posts', variables.postId], context.previousPost);
}
},
});
Key Takeaways
- Optimistic updates update the UI immediately, assuming success, then confirm or rollback based on the server response.
- Use
onMutateto update cache and save a snapshot; useonErrorto rollback using that snapshot. - Temporary IDs (e.g.,
temp-${timestamp}) distinguish optimistic from confirmed data; replace them on success. - Show subtle visual indicators (opacity, loading spinners) for pending optimistic data so users understand they're unconfirmed.
- Always rollback with clear error messages; silent restoration leaves users confused about what happened.
- For nested updates, use spread operators to immutably merge changes into deep objects.
Frequently Asked Questions
What if the server response differs from my optimistic update?
Replace the optimistic data with the server response in onSuccess. For example, if the server calculates a timestamp, use the server's value, not your guessed one.
How do I know if a piece of data is optimistic?
Use a temporary identifier, flag, or timestamp comparison. Optimistic comments often have id.startsWith('temp-') or a future createdAt timestamp until the server confirms.
Can I use optimistic updates without React Query?
Yes, but you'd manually manage state and rollback logic. React Query's onMutate, onSuccess, and onError callbacks are purpose-built for this pattern.
Is it safe to optimize ALL mutations?
Not always. Financial transactions, destructive operations (deletes), or operations requiring validation should wait for server confirmation. Optimistic updates work best for idempotent operations or those with forgiving rollback (likes, comments, non-critical updates).
What if the user navigates away before the mutation confirms?
The mutation continues in the background. If it fails, the cache is rolled back, but the user won't see the error (they're on another page). Log errors server-side and send notifications if critical.