Optimistic UI with useOptimistic: lying to the user honestly
useOptimistic renders the hoped-for state while the mutation flies and reverts to canonical state when the transition settles — rollback is free. Reconciliation, idempotency keys against double-submit, and knowing when optimism is wrong (payments, deletes) stay on you.
Two bugs from the same feed team, six weeks apart. First, the double-like: a user on hotel wifi taps the heart, sees nothing for two seconds (the team showed the like only after the server confirmed), assumes the tap missed, taps again. Both requests eventually land; the counter reads 2 from one human gesture, and the like toggle is now inverted — tapping once more “unlikes” them to 1 instead of 0. Second, the vanishing comment: after the team went optimistic by hand, a user posts a comment, sees it appear instantly — then watches it evaporate three seconds later. The hand-rolled rollback was a catch block splicing the comment out of local state, but a refetch triggered by another component raced it: the refetch snapshot (taken before the insert) overwrote the list, then the splice ran against the wrong array. The comment had actually saved; the UI just lost the plot. Optimistic UI is the correct call for a like button — perceived latency drops from round-trip time to zero — but hand-rolled optimism is distributed-systems homework: you are maintaining two timelines, local hope and server truth, and every merge between them is a chance to lie. React 19 ships the merge discipline as a hook.
The mechanism: an overlay that expires
useOptimistic(canonicalState, mergeFn) returns the state to render plus addOptimistic. The semantics are the part to internalize: the hook does not fork your state — it renders an overlay. While no mutation is pending, it returns the canonical state untouched. When you call addOptimistic(input) inside a transition (a form action is one), React renders mergeFn(canonicalState, input) — canonical state plus your hope — for as long as the transition is in flight. When the transition settles, the overlay expires and the hook returns whatever the canonical state is now. There is nothing to undo, because nothing was ever written: rollback on failure is not a code path you author, it is the overlay expiring while the canonical state happens not to have changed.
function Comments({ comments, postComment }) {
const [optimisticComments, addOptimistic] = useOptimistic(
comments,
(current, newComment) => [
...current,
{ ...newComment, pending: true },
]
);
async function action(formData) {
const text = formData.get("text");
addOptimistic({ id: crypto.randomUUID(), text });
await postComment(text); // on success, parent refetches → comments updates
}
return (
<form action={action}>
<ul>
{optimisticComments.map((c) => (
<li key={c.id} style={{ opacity: c.pending ? 0.5 : 1 }}>
{c.text}
</li>
))}
</ul>
<input name="text" />
</form>
);
}Success path: the action awaits the POST, the parent updates comments (refetch or response merge), the transition settles, and the overlay is replaced by canonical state that now contains the comment — the user never sees a seam. Failure path: the action throws or returns an error, canonical state never changed, the overlay expires — the comment fades out on its own. The pending: true flag is the honesty marker: dimming or a subtle spinner tells the user this item is a promise, not a fact, which also makes the rare disappearance comprehensible instead of haunted. The vanishing-comment bug from the Hook cannot be expressed in this model — there is no splice to race, because there is no second writable copy of the list.
Reconciliation: server truth wins, including when it disagrees
The overlay answers “what while we wait” — reconciliation answers “what when truth arrives”. The rule is absolute: the optimistic value is a prediction, and the server’s answer replaces it even when they differ. The server may normalize the comment text, attach moderation flags, assign the real id and timestamp; canonical state must be built from the response (or a refetch), never by promoting the optimistic object into the real list. Teams that promote the prediction get drift: the UI shows the pre-moderation text until the next full reload, and the temporary client id leaks into anchors and analytics.
Identity is the sharp edge. Your optimistic item has a client-generated key; the server item has the real id. If the merge is by array position or by sloppy equality, a slow success can briefly show both — the dimmed prediction and the confirmed item. Carry a client key on the optimistic item, have the server echo it back (most APIs will return what you sent), and match on it during the merge.
Idempotency: surviving the second tap
Optimism makes the UI instant but does nothing about the network, and users double-submit for good reasons: the button did not respond visually, the page froze, hotel wifi. Disabling the button while isPending helps locally but cannot help if the first request already left and the response was lost — the user retries, and the server, having no memory, executes the mutation twice. The double-like, the double comment, and in the worst genre, the double charge.
The fix is an idempotency key: the client generates a unique key per intent (not per request) — crypto.randomUUID() at the moment of the gesture — and sends it with the mutation; the retry sends the same key. The server stores keys it has processed and returns the recorded result for a repeat instead of executing again. This converts “at least once” delivery into “exactly once” effect, and it is the same contract payment APIs expose for exactly this reason. Note the division of labor: React gives you the overlay and the pending flag; idempotency is a protocol between your client code and your API, and no hook can supply it.
▸Why this works
Why does useOptimistic require a transition or action? Because the overlay’s lifetime is defined by one: it shows from addOptimistic until the surrounding transition settles. Called outside any transition, there is no bracket to scope the hope to — React 19.x warns that an optimistic update was made outside a transition and the value snaps back almost immediately. The pairing is the design: actions give mutations a tracked lifetime, and useOptimistic rents exactly that lifetime for its lie.
When optimism is the wrong bet
Optimism is a wager that success is overwhelmingly likely and failure is cheap to take back. Like buttons, follows, emoji reactions, marking-as-read, reordering a todo list: success rates in the high 99s, rollback is a fade — bet every time. The wager inverts in three situations. Money and anything legally binding: showing “Payment complete” before the processor answers is not latency-hiding, it is a false receipt — the user closes the tab and walks away believing a thing that may be false. Destructive, hard-to-reverse operations: optimistically vanishing “Delete account” or “Cancel subscription” confirms an irreversible act the server might still reject — and the user acts on the confirmation. Low-confidence mutations: anything with a meaningful rejection rate (moderated content in a strict community, seat selection under contention, inventory holds) — when failure is a routine outcome rather than an exception, the UI keeps “taking back” what it showed, and each retraction burns trust that optimism was supposed to build. For these, the pending-honest pattern wins: disable, show progress, confirm on truth. The user forgives 800 ms of spinner on a payment; they do not forgive a receipt that retracts.
With useOptimistic, the mutation fails (the action throws) and the optimistic comment disappears. Where is the rollback code that removed it?
A user taps Like, the request succeeds but the response is lost in a wifi dropout, the user taps again, and the count lands at 2. Which fix addresses the root cause?
- 01Explain the overlay model of useOptimistic and why it structurally prevents the vanishing-comment race that hand-rolled optimism suffers.
- 02A senior review checklist for an optimistic mutation: what three guarantees must be in place beyond calling useOptimistic, and when should the reviewer reject optimism entirely?
Optimistic UI collapses perceived mutation latency from a network round-trip to zero by rendering the hoped-for state immediately — and React 19’s useOptimistic makes the dangerous half of that bargain structural. The hook returns an overlay, not a fork: outside a pending transition you see canonical state; after addOptimistic inside a transition you see the merge of canonical state and your hope, until the transition settles and the overlay expires. Success replaces hope with server truth because the action updated canonical state; failure requires no rollback code at all, because nothing was ever written — which is precisely why the hand-rolled catch-and-splice race (the vanishing comment overwritten by a stale refetch) cannot be expressed in this model. What stays on you: reconciliation, where the server’s answer always replaces the prediction — rebuild canonical state from the response, match items by an echoed client key, never promote the optimistic object; idempotency, where a per-gesture key stored by the server turns the inevitable double-submit (lost response, second tap, the double-like landing at 2) into a replayed result instead of a second execution; and judgment, because optimism is a bet that success is near-certain and retraction is cheap. Take the bet for likes, follows, reorders, mark-as-read. Refuse it for payments, destructive operations, and any mutation whose rejection is routine — a UI that keeps retracting what it showed spends the trust it was built to earn.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.
Apply this
Put this lesson to work on a real build.