Optimistic UI Patterns That Actually Work
Real-world patterns for building fast, predictable UIs with Zustand and React Query — and how to handle rollbacks gracefully when things go wrong.
What Optimistic UI Actually Means
Optimistic UI means updating the interface before the server confirms the change. You assume the operation will succeed — hence "optimistic" — and show the result immediately. If the server rejects it, you roll back.
Done right, this makes apps feel instant. Done wrong, it creates inconsistencies that erode user trust. Most tutorials show you the happy path. This post covers the full picture: updates, rollbacks, loading states, and the edge cases that bite you in production.
The Basic Pattern with React Query
React Query's useMutation has built-in support for optimistic updates via onMutate, onError, and onSettled:
const queryClient = useQueryClient();
const likeMutation = useMutation({
mutationFn: (postId: string) => api.likePost(postId),
onMutate: async (postId) => {
// 1. Cancel any in-flight refetches to prevent overwriting
await queryClient.cancelQueries({ queryKey: ["posts"] });
// 2. Snapshot the current value for rollback
const previous = queryClient.getQueryData(["posts"]);
// 3. Optimistically update the cache
queryClient.setQueryData(["posts"], (old: Post[]) =>
old.map((p) => p.id === postId ? { ...p, likes: p.likes + 1 } : p)
);
// 4. Return context for rollback
return { previous };
},
onError: (err, postId, context) => {
// Rollback to the snapshot
queryClient.setQueryData(["posts"], context?.previous);
},
onSettled: () => {
// Always refetch to sync with server truth
queryClient.invalidateQueries({ queryKey: ["posts"] });
},
});
The three-step pattern — snapshot, update, return context — is the foundation. Never skip the snapshot.
Handling Concurrent Mutations
The pattern above breaks when a user fires multiple mutations quickly. Like button spam is the classic case. The fix is to use a queue and only roll back the specific mutation that failed, not all of them:
// Track pending optimistic updates separately
const pendingLikes = useRef(new Set<string>());
onMutate: async (postId) => {
pendingLikes.current.add(postId);
// ... update cache by counting pendingLikes
},
onSettled: (data, error, postId) => {
pendingLikes.current.delete(postId);
queryClient.invalidateQueries({ queryKey: ["posts"] });
},
The Zustand Approach
For local state that doesn't need server sync, Zustand gives you more control. Here's a todo list with optimistic delete and undo:
interface TodoStore {
todos: Todo[];
deleteTodo: (id: string) => void;
undoDelete: (todo: Todo) => void;
}
const useTodoStore = create<TodoStore>((set, get) => ({
todos: [],
deleteTodo: (id) => {
const todo = get().todos.find((t) => t.id === id);
// Remove immediately from UI
set((s) => ({ todos: s.todos.filter((t) => t.id !== id) }));
// Fire the API
api.deleteTodo(id).catch(() => {
// Restore on failure
if (todo) set((s) => ({ todos: [...s.todos, todo] }));
toast.error("Couldn't delete. Restored.");
});
},
}));
Showing the Right Loading States
Optimistic UIs shouldn't be invisible. Users need subtle feedback that their action was received, even before server confirmation:
function LikeButton({ post }: { post: Post }) {
const { mutate, isPending } = useLikeMutation();
return (
<button
onClick={() => mutate(post.id)}
style={{ opacity: isPending ? 0.7 : 1 }}
aria-label={`Like post. ${post.likes} likes.`}
>
<Heart
fill={post.isLiked ? "currentColor" : "none"}
// Scale pulse on pending
className={isPending ? "animate-pulse" : ""}
/>
{post.likes}
</button>
);
}
Small opacity changes and pulse animations signal "working on it" without blocking the user.
The Golden Rules
Three rules I've settled on after shipping many optimistic UIs:
1. Always snapshot before mutating. You cannot roll back what you didn't save.
2. Invalidate on settle, not on success. onSettled fires whether the mutation succeeded or failed. Invalidating there ensures you always eventually sync with the server.
3. Make rollbacks visible. A silent rollback is worse than an error message. Use a toast or inline error so users know their action didn't land and can try again.
Optimistic UI is a UX investment. Get the error paths right and it builds trust. Get them wrong and it destroys it.
Related Articles
The Underrated Power of CSS Grid Subgrid
Subgrid finally has solid browser support. Here's how it solves alignment problems that used to require JavaScript hacks, and why you should be using it today.