The first time I built an optimistic Like button, I thought I had cracked some secret the rest of the internet was missing. The heart filled in instantly. The number ticked up the moment the mouse came back from the click. The whole thing felt the way a native iOS app feels — frictionless, immediate, almost weightless.
Then I went on a flight, came back online, opened the same app over a flaky cafe Wi-Fi, and watched it lie to me. Hearts I had filled in were still empty after refresh. A comment I had clearly posted was just gone. A profile change "saved" five minutes ago had quietly never happened. The illusion of speed had become an illusion of correctness, and that one is much harder to forgive.
Optimistic updates are UX magic. They are also a small contract you sign with your user — that the thing you just showed them is actually true. The whole problem is what happens when it isn't.
What Optimistic UI Actually Buys You
The pessimistic version of any mutation looks like this: user clicks, you show a spinner, you wait for the network round-trip, you update the UI when the response comes back. On a fast connection that is 80–200ms. The threshold for an interaction feeling instant is roughly 100ms. You are right at the line, and on a worse connection you are well past it.
The optimistic version moves the UI update before the network call. The heart fills in the moment the mouse releases. The list reorders the moment the toggle flips. The network request fires in parallel, and the UI quietly reconciles with the truth when the response lands.
When the request succeeds — most of the time on a healthy product — the user perceives zero latency. That is real, measurable, and worth doing. But the moment the request fails, you owe them an honest correction.
React 19 Made This A First-Class Pattern
useOptimistic shipped with React 19 and is the cleanest way to express "I know the real state, but render this temporary version while the action is in flight." It pairs naturally with the useTransition and form actions added in the same release.
import { useOptimistic, useTransition } from 'react';
function LikeButton({ post, toggleLike }: Props) {
const [optimisticPost, setOptimistic] = useOptimistic(
post,
(current, _action: 'like' | 'unlike') => ({
...current,
isLikedByMe: !current.isLikedByMe,
likeCount: current.isLikedByMe ? current.likeCount - 1 : current.likeCount + 1,
}),
);
const [pending, startTransition] = useTransition();
return (
<button
onClick={() => {
startTransition(async () => {
setOptimistic(optimisticPost.isLikedByMe ? 'unlike' : 'like');
await toggleLike(post.id);
});
}}
aria-pressed={optimisticPost.isLikedByMe}
disabled={pending}
>
{optimisticPost.likeCount}
</button>
);
}
useOptimistic automatically discards the optimistic value when the surrounding action either resolves with new server state or throws. You do not write the rollback by hand. That alone removes a category of bug.
For lists, the same hook works well by reducing onto an array — push the new item with status: 'pending', render it slightly faded, and let the action's resolution either confirm it or unwind the optimistic insertion.
TanStack Query: onMutate And The Rollback Context
For client components and apps that haven't migrated to React 19's action APIs, TanStack Query's mutation lifecycle is the most battle-tested way to do this. The shape is always the same: cancel in-flight queries that would clobber the optimistic value, snapshot the previous state, set the new state, then on error restore from the snapshot.
const queryClient = useQueryClient();
const likeMutation = useMutation({
mutationFn: (postId: string) => api.likePost(postId),
onMutate: async (postId) => {
await queryClient.cancelQueries({ queryKey: ['post', postId] });
const previous = queryClient.getQueryData<Post>(['post', postId]);
queryClient.setQueryData<Post>(['post', postId], (old) =>
old
? {
...old,
isLikedByMe: !old.isLikedByMe,
likeCount: old.isLikedByMe ? old.likeCount - 1 : old.likeCount + 1,
}
: old,
);
return { previous };
},
onError: (err, postId, context) => {
if (context?.previous) queryClient.setQueryData(['post', postId], context.previous);
toast.error('Could not save your like — please try again.');
},
onSettled: (_data, _err, postId) => {
queryClient.invalidateQueries({ queryKey: ['post', postId] });
},
});
Three things are worth noticing. The cancelQueries call prevents a stale GET /post/:id in flight from overwriting the optimistic value when it lands a moment after the mutation. The context.previous snapshot is the rollback target — explicit, typed, returned from onMutate so onError can use it. The onSettled invalidation forces a refetch after either outcome, so the local cache reconciles with the server even when both paths "succeeded."
SWR has the same ideas with a different API: mutate(key, fetcher, { optimisticData, rollbackOnError: true, revalidate: true }). Apollo Client uses optimisticResponse on mutations and updates the cache through the normal update callback. The pattern is identical across the libraries — pre-image, optimistic value, error rollback, post-success reconciliation.
The Failure Modes Most People Miss
Two requests in flight at the same time. The user clicks Like, then Unlike, before the first request finishes. If the first request was rolled back as the second one started, you have a race. Most libraries handle this correctly when you cancel queries, but if your mutation logic accumulates state across calls, you can end up in inconsistent territory. Track an action sequence number per mutation and discard responses that are out of order.
The server returns success but means something different than the client assumed. The user liked a post; the server quietly capped them at five likes per minute and ignored the request. The HTTP response was 200. Your optimistic UI is still wrong. The fix is for the server to return the canonical state — { likeCount: 7, isLikedByMe: false } — and for the client to reconcile with it on success, not just on error.
A 5xx that is also a network blip. Your code retries the request; it succeeds the second time. Meanwhile the user has navigated away and the list is no longer mounted. Whatever rollback or reconciliation logic you wrote needs to survive component unmount — store the source of truth in the query cache, not in component state.
The user goes offline mid-action. The optimistic UI shows their change. The mutation throws when it actually fires. You roll back. The user sees their action quietly disappear with no explanation. A small "you are offline; we will retry" banner is worth a lot here. TanStack Query has built-in support for queuing mutations until the network returns.
When Not To Be Optimistic
The most important rule about optimistic UI is knowing when not to use it. The frame I keep coming back to: optimistic updates are a promise to the user that the action they just took is going to happen. Make that promise where you can keep it, and stay pessimistic everywhere you can't.
Things that are great candidates:
- Toggling Likes, follows, bookmarks, reactions.
- Reordering items in a list the user owns.
- Marking a to-do done.
- Adding to a cart.
- Local profile-display tweaks (display name, theme, language).
Things that should never be optimistic:
- Payments. Never display "Payment successful" before the gateway confirms. The cost of being wrong is a refund, a support ticket, and a user who does not trust your checkout again.
- Deletes that cannot be reversed. If a click destroys data, make the user wait for confirmation. Even better, do soft deletes plus an Undo banner — that is optimistic by another name and far safer.
- Anything subject to non-trivial server-side validation. If the server might reject an action for reasons the client cannot anticipate (inventory limits, concurrent edits, fraud rules), pessimistic feedback is the honest UX.
- State changes that affect other users. Sending a message is fine to show locally; transferring ownership of a shared resource is not.
A small heuristic: if the worst-case rollback is "the heart pops back to empty," go optimistic. If the worst case is "the user thinks they did something they didn't," do not.
Reconcile, Don't Just Roll Back
The detail that separates production-quality optimistic UI from the demo version is what happens after success. The optimistic value was your guess. The server's response is the truth. The local cache should reflect the truth, not the guess.
const likeMutation = useMutation({
mutationFn: (postId: string) => api.likePost(postId),
onMutate,
onError,
onSuccess: (serverPost) => {
queryClient.setQueryData(['post', serverPost.id], serverPost);
},
});
If your mutation endpoint returns the canonical record, this single line keeps the UI honest forever. If it does not, refetch on success — the latency is hidden by the optimistic UI you already painted.
This is also the place to handle subtle bugs like "the like went through but the user got muted, so the like count went up but the post is now greyed out." The server knows; the optimistic guess does not. Always trust the response.
A One-Sentence Mental Model
Optimistic UI is a promise that the action you just rendered will actually happen — keep it cheap, roll it back honestly when the network disagrees, reconcile with the server on success, and reserve pessimistic feedback for the actions where being wrong would break trust instead of just bouncing the heart back to empty.




