Optimistic UI needs an exit animation
Rendering a mutation before the server confirms it is the easy half. The hard half is what the interface does when you were wrong.
Optimistic updates get sold as a performance trick: skip the round trip, render the result, feel fast. That's true and it's also the least interesting part. The reason optimistic UI is hard is that you've made a promise on the server's behalf, and you need a graceful way to break it.
The shape in React 19
useOptimistic handles the state. It takes the real value and a reducer, and gives you back a value that includes pending changes until the transition settles.
"use client";
export function Comments({ comments }: { comments: Comment[] }) {
const [optimistic, addOptimistic] = useOptimistic(
comments,
(state, draft: string) => [...state, { id: "pending", body: draft, pending: true }],
);
async function submit(formData: FormData) {
const body = String(formData.get("body"));
addOptimistic(body);
await postComment(body); // throws -> React discards the optimistic entry
}
return (
<form action={submit}>
{optimistic.map((comment) => (
<Comment key={comment.id} {...comment} />
))}
</form>
);
}When the action throws, React drops the optimistic entry and re-renders with the real list. Correct — and visually brutal. The row the user just created vanishes between two frames, with no indication of whether it failed, was rejected, or was never sent.
Make the rollback legible
The fix isn't more state, it's giving the removal a duration. Wrap the list in AnimatePresence and the discarded row animates out, which buys you the ~200ms you need to also say why.
- Shipped the new easing tokens
One in three sends fails. Try a few.
const [optimistic, addOptimistic] = useOptimistic(
messages,
(state, draft) => [...state, { id: `pending-${draft}`, body: draft, state: "pending" }],
);
startTransition(async () => {
addOptimistic(body);
try {
const saved = await send(body);
setMessages((current) => [...current, saved]);
input.value = ""; // clear only once the server has it
} catch {
setFailed({ id: `failed-${body}`, body, state: "failed" });
}
});
// The rollback needs a duration, or the row disappears between two frames.
<AnimatePresence initial={false} mode="popLayout">
<motion.li
exit={{ opacity: 0, y: -4, transition: { duration: 0.16 } }}
/>
</AnimatePresence>;Rules that have held up
- Never optimistically render something irreversible. Payments, deletes, sends — those get a real pending state.
- Pending isn't a spinner. Render the value at reduced contrast; it communicates not yet without implying blocked.
- Keep the input's content until the server confirms. Clearing it early means a failed submit eats the text.
- Say what failed, not that something failed. The error belongs next to the row, not in a corner toast.
Done well, nobody notices any of it — which is the entire job. Optimistic UI is the rare feature where success looks exactly like nothing happening.