Skip to content
César Venzac

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.

2 min readreact, stateUpdated

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.

components/comment-form.tsx
"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.

Send a few. One in three fails on purpose — watch what the failure does on its way out.

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.