Why Your UI Feels Slow
User clicks 'Like'. Nothing happens for 300ms. Button finally changes state.
That 300ms delay is your network round-trip. The server almost certainly succeeded. But the UI waited anyway.
Optimistic updates flip this: assume success, update immediately, rollback if wrong.
The Pattern
// Before: wait for server
async function likePost(postId: string) {
const response = await fetch(`/api/posts/${postId}/like`, { method: 'POST' });
const updated = await response.json();
setPosts(posts.map(p => p.id === postId ? updated : p));
}
// After: update immediately, sync in background
async function likePost(postId: string) {
// 1. Update UI immediately
setPosts(posts.map(p =>
p.id === postId ? { ...p, likes: p.likes + 1, liked: true } : p
));
try {
// 2. Sync with server
await fetch(`/api/posts/${postId}/like`, { method: 'POST' });
} catch (error) {
// 3. Rollback on failure
setPosts(posts.map(p =>
p.id === postId ? { ...p, likes: p.likes - 1, liked: false } : p
));
toast.error('Failed to like post');
}
}
With React Query
React Query has built-in optimistic update support:
import { useMutation, useQueryClient } from '@tanstack/react-query';
function useLikePost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (postId: string) =>
fetch(`/api/posts/${postId}/like`, { method: 'POST' }).then(r => r.json()),
onMutate: async (postId) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: ['posts'] });
// Snapshot the previous value
const previousPosts = queryClient.getQueryData(['posts']);
// Optimistically update
queryClient.setQueryData(['posts'], (old: Post[]) =>
old.map(p => p.id === postId
? { ...p, likes: p.likes + 1, liked: true }
: p
)
);
// Return context with snapshot for rollback
return { previousPosts };
},
onError: (error, postId, context) => {
// Rollback to snapshot
queryClient.setQueryData(['posts'], context?.previousPosts);
toast.error('Failed to like post');
},
onSettled: () => {
// Always refetch to sync with server truth
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
}
// Usage
function PostCard({ post }: { post: Post }) {
const { mutate: likePost } = useLikePost();
return (
<button onClick={() => likePost(post.id)}>
{post.liked ? '❤️' : '🤍'} {post.likes}
</button>
);
}
With SWR
import useSWR, { mutate } from 'swr';
function usePost(postId: string) {
return useSWR(`/api/posts/${postId}`, fetcher);
}
async function likePost(postId: string) {
// Optimistic update with SWR
await mutate(
`/api/posts/${postId}`,
async (currentPost: Post) => {
// This runs after the optimistic update
const updated = await fetch(`/api/posts/${postId}/like`, {
method: 'POST'
}).then(r => r.json());
return updated;
},
{
optimisticData: (currentPost: Post) => ({
...currentPost,
likes: currentPost.likes + 1,
liked: true,
}),
rollbackOnError: true,
}
);
}
More Complex Example: Todo List
function useTodos() {
const queryClient = useQueryClient();
const { data: todos } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
const addTodo = useMutation({
mutationFn: createTodo,
onMutate: async (newTodoText: string) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previousTodos = queryClient.getQueryData(['todos']);
// Add with temp ID
const optimisticTodo = {
id: `temp-${Date.now()}`,
text: newTodoText,
completed: false,
_isOptimistic: true, // Visual indicator
};
queryClient.setQueryData(['todos'], (old: Todo[]) => [
...old,
optimisticTodo,
]);
return { previousTodos };
},
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context?.previousTodos);
},
onSuccess: (newTodo) => {
// Replace optimistic with real todo
queryClient.setQueryData(['todos'], (old: Todo[]) =>
old.map(t => t._isOptimistic ? newTodo : t)
);
},
});
return { todos, addTodo };
}
When NOT to Use Optimistic Updates
- Financial transactions: Don't optimistically show a payment succeeded
- Destructive operations: Deleting something permanently needs confirmation
- High failure rate operations: If 20% of requests fail, optimistic updates cause confusion
- Real-time collaborative data: Risk of showing stale state to users
The Rules
- Always snapshot before mutating
- Always rollback on error with user-visible feedback
- Always eventually sync with server truth (
onSettledinvalidation) - Show a loading indicator for operations > 500ms even with optimistic updates
Most CRUD operations in a typical SaaS succeed > 99% of the time. For those, the 300ms you save matters more than the edge case rollback.
Building a React SaaS with great UX patterns? Whoff Agents AI SaaS Starter Kit includes React Query setup with optimistic update patterns pre-wired.
Top comments (0)