Waiting for the server before showing a result is honest and slow. A chat message that appears 400 ms after you press Enter feels broken; a heart icon that fills in a beat after you tap it makes people tap again. Optimistic UI shows the expected result immediately and corrects it if the server disagrees.
Hand-rolled optimistic updates are where bugs pile up: the temporary item that never gets removed, the rollback that restores the wrong snapshot, the duplicate when the real item arrives. React 19’s useOptimistic handles the bookkeeping by tying the temporary value to the lifetime of an action. Knowing exactly when that value appears and disappears is what interviewers want to hear.
How the hook thinks about state
const [optimisticMessages, addOptimistic] = useOptimistic(messages, (current, text) => [
...current,
{ id: `tmp-${crypto.randomUUID()}`, text, sending: true },
]);messagesis your real state, fromuseState, a prop, or server data.- The reducer describes how to apply an optimistic change on top of the current value.
optimisticMessagesis what you render. When no action is in progress, it’s simplymessages.addOptimistic(text)applies the reducer, but only for the duration of the action it’s called in.
The key idea: optimistic updates are a temporary layer, not a change to your state. While an action is pending, React renders real state with the pending optimistic updates applied on top. When the action finishes, the layer is dropped and React renders real state alone. If real state was updated with the server’s result before then, the user sees the confirmed item. If it wasn’t, because the request failed, the optimistic item is simply gone. Rollback isn’t something you write; it’s what happens when you don’t commit.
A worked example: sending a chat message
function Thread({ initialMessages }) {
const [messages, setMessages] = useState(initialMessages);
const [error, setError] = useState(null);
const [optimisticMessages, addOptimistic] = useOptimistic(messages, (current, text) => [
...current,
{ id: `tmp-${crypto.randomUUID()}`, text, sending: true },
]);
function send(text) {
startTransition(async () => {
setError(null);
addOptimistic(text);
try {
const saved = await saveMessage(text); // POST, resolves to { id, text }
startTransition(() => setMessages((m) => [...m, saved]));
} catch {
setError(`"${text}" was not sent. Check your connection and try again.`);
}
});
}
return (
<>
<ul>
{optimisticMessages.map((m) => (
<li key={m.id} className={m.sending ? 'is-sending' : undefined}>
{m.text}{m.sending && <span className="sr-only"> (sending)</span>}
</li>
))}
</ul>
{error && <p role="alert">{error}</p>}
<Composer onSend={send} />
</>
);
}Running a small version of this with React 19 in jsdom, where saveMessage takes 50 ms and can be told to fail, gave this sequence (each line is the rendered list):
t=0 Hi, Lunch? (sending)
t=80 Hi, Lunch?
t=0 Hi, Lunch?, 12:30 ok? (sending)
t=80 Hi, Lunch? | "12:30 ok?" was not sentWalk through what happened:
send('Lunch?')started a transition.addOptimisticadded a pending item, and React rendered it immediately with the “sending” marker.- The save resolved.
setMessagesadded the real message to state. When the action finished, the optimistic layer was dropped, and the list showed the real “Lunch?” with no marker. No duplicate, because the temporary item only ever existed in the layer. send('12:30 ok?')added another pending item on top of the now-real “Lunch?”.- The save threw. The catch set an error message but never touched
messages. When the action ended, the layer was dropped and the pending item vanished, leaving the error to explain why.
Why the second startTransition is there
That inner startTransition around setMessages looks redundant, and it’s a known React 19 caveat. State updates that happen after an await inside an async transition aren’t automatically part of the transition, because React can’t track the async context across the await. Without the wrapper, setMessages would commit as an urgent update, potentially before the action ends, and you could see the real message and the optimistic one at the same time for a frame. Wrapping it keeps the commit inside the transition.
The outside-transition trap
addOptimistic must be called inside an action: a function passed to <form action>, a startTransition callback, or an action prop from a library or framework. Call it from a plain onClick and React warns:
An optimistic state update occurred outside a transition or action. To fix, move the update to an action, or wrap with startTransition.In the jsdom run, the stray update rendered nothing new. There was no action whose lifetime the optimistic value could follow, so React had nothing to show it against. Forms are the most natural place for this hook: pass an async function to <form action> and anything you call inside it, including addOptimistic, runs in the action.
Designing the user-facing behaviour
The hook handles state; the experience is still your job.
Mark pending items. Grey text, a small clock icon, “Sending…” for screen readers. Users should be able to tell a confirmed message from one still in flight, because the in-flight one can still disappear.
Never let a failure vanish silently. In the example, the failed message disappears from the list. Without the error message, the user would assume it was sent. Better still, keep the failed text in your real state with a failed: true flag and a “Retry” button, so nothing they typed is lost.
Pick operations that usually succeed. Likes, read receipts, reordering, toggling a checkbox, sending a message: failures are rare and easy to explain. Don’t be optimistic about payments, bookings with limited stock, or anything where showing success and then reversing it causes real harm or confusion.
Use stable keys. The temporary ID and the server ID differ, so React treats the confirmed message as a new list item. For a text row that’s fine. For an item with an input or animation, generate a client ID before saving, send it to the server, and use it as the key for both versions so React keeps the same element.
How this compares to hand-rolled rollback
Without the hook you’d snapshot state before the change, apply it, and restore the snapshot on failure. That breaks with concurrent operations: like post A, like post B, A fails, and restoring A’s snapshot also reverts B’s like. With useOptimistic, each failed action just stops contributing its layer; successful actions have already written to real state, so nothing else is undone. Data libraries such as TanStack Query offer similar optimistic helpers, and if you already use one, use its mechanism rather than layering both.
Practice question: the ghost like
“A like button uses useOptimistic. Users report that the heart fills, then empties a second later, even though the server returns 200 and the count is correct after a refresh. What’s wrong?”
Answer. The optimistic layer disappears when the action ends, so the heart staying filled depends on real state already reflecting the like by then. The server succeeds, which means the real state isn’t being updated, or isn’t updated in time. The most likely causes: the action awaits the request but never sets the real liked/count state or refetches it; it updates state after the await without wrapping that update in startTransition, so the timing is off; or real state comes from a cache that’s invalidated but refetched only after the action resolves, leaving a gap. I’d make the action write the server’s response into real state inside a nested startTransition before returning, or await the refetch inside the action so the transition stays pending until fresh data arrives. Then I’d test both paths: success keeps the heart filled with the server’s count, and a simulated 500 empties it and shows a short error.