Optimistic UI Patterns
When you tap “like” on a tweet, the heart turns red instantly. There is no spinner. There is no “saving…” toast. The network call to update the count is happening in the background, but the UI doesn’t wait for it.
That’s optimistic UI. The interface assumes the action will succeed and updates immediately. The actual server response either confirms the optimistic state (silently, the user already sees the new state) or undoes it (rare, requires a graceful rollback).
It’s a small idea with big payoffs for perceived performance, and it’s been sitting inside GraphQL clients like Apollo and Relay for years. React 19 finally lifted it into the framework with useOptimistic. Let me walk through where I’ve seen it work, where it bites, and how to build it without losing your mind.
The mental model
There are two states for any mutation:
- Server state: what the backend knows. Authoritative, eventually consistent.
- Optimistic state: what we predict the server state will become.
The UI renders the optimistic state. When the mutation resolves, the optimistic state is dropped (or merged) and the UI re-renders from the new server state. If the mutation fails, the optimistic update is rolled back and an error shown.
The key invariant: the optimistic update must be a function of the current server state and the action. If you mutate the optimistic state directly without this discipline, rollback gets ugly fast.
Apollo’s optimisticResponse: where I first saw the pattern
I’ve been using GraphQL with Apollo Client at work for years. The optimisticResponse option on a mutation is the same idea wrapped in nice ergonomics:
const [likePost] = useMutation(LIKE_POST, {
variables: { postId: "abc123" },
optimisticResponse: {
__typename: "Mutation",
likePost: {
__typename: "Post",
id: "abc123",
likeCount: post.likeCount + 1,
likedByMe: true,
},
},
});
When you call likePost(), Apollo immediately writes that fake response into its in-memory cache. Any component reading the cache (via useQuery or useFragment) re-renders with the new value. The server’s actual response, which arrives some milliseconds later; overwrites the cache again with the real value, which usually matches.
If the mutation fails, Apollo rolls back the cache to what it was before the optimistic write. The UI re-renders from the rolled-back state, and you handle the error in the onError callback.
The whole thing is invisible to the component that triggered the mutation. That’s the elegant part.
React 19’s useOptimistic
React 19 ships a hook that gives you the same primitive without an external state library. The reference is at react.dev/reference/react/useOptimistic.
import { useOptimistic, useTransition } from "react";
function LikeButton({ post }: { post: Post }) {
const [optimisticPost, addOptimistic] = useOptimistic(
post,
(currentPost, _action: "like" | "unlike") => ({
...currentPost,
likedByMe: !currentPost.likedByMe,
likeCount: currentPost.likeCount + (currentPost.likedByMe ? -1 : 1),
}),
);
const [isPending, startTransition] = useTransition();
const handleClick = () => {
startTransition(async () => {
addOptimistic("like");
await likePostOnServer(post.id);
// Server state will refresh via parent suspense / query
});
};
return (
<button onClick={handleClick} disabled={isPending}>
{optimisticPost.likedByMe ? "♥" : "♡"} {optimisticPost.likeCount}
</button>
);
}
The hook takes the source of truth (post, usually from a query / loader) and a reducer that produces the optimistic state. While the surrounding transition is pending, React renders optimisticPost. When the transition completes, React drops the optimistic state and renders directly from post again.
The mental model is identical to Apollo’s, an optimistic value derived from the current real value plus the action.
Where it works beautifully
- Likes, votes, stars - small, idempotent, low-stakes counters.
- Marking todos done / undone - a single boolean toggle.
- Reordering lists - drag-and-drop where the new order is computed locally before the server saves it.
- Adding items to a list - an optimistic placeholder with a temporary ID, replaced when the real ID arrives.
For all of these, the failure case is rare and the rollback is small.
Where it bites
- Pagination and counts that compound. If you optimistically increment a count and the server returns a different value (because someone else also liked it), you get a flicker. Or worse, your optimistic count diverges from reality permanently.
- Forms with server-side validation. “Looks fine on the client, server says no” is a worse UX than “wait a moment, validating”. Use optimistic updates only when client-side validation is enough.
- Navigation as a side effect. If a successful mutation triggers a route change, optimistic UI gets confusing - the user already moved on, where does the rollback toast go?
- Multi-step transactions. If a single user action requires three mutations, optimistic UI gets harder to reason about. The “predicted final state” might disagree with each individual server result.
The rule of thumb: if you can’t articulate the rollback in one sentence, you probably shouldn’t be optimistic.
A pragmatic checklist before going optimistic
- Is the mutation low-stakes? A wrong like-count is forgivable. A wrong bank balance isn’t.
- Can the optimistic state be derived purely from the current state plus the action? If you’d need to call out to the server to compute it, you’ve defeated the point.
- What’s the rollback? Can you compute it from the original state, or do you need to keep a snapshot?
- What does failure look like to the user? A toast? A revert with no explanation? Decide before you ship.
- Is there a temporary ID problem? If you’re adding items to a list, you’ll need to swap a temp ID for the real one when the server responds.
The pattern itself isn’t new. Apollo and Relay have shipped it for years. Tanstack Query has onMutate for the same job. React Server Actions and useOptimistic are the latest version, but the mental model is unchanged: derive the optimistic state, render it, reconcile when the truth arrives.
What’s changed is that you no longer need a heavy client cache to get it. A loader function and useOptimistic are enough. That makes the pattern accessible from a much smaller codebase, which is probably the bigger story than the hook itself.