useOptimistic

Optimistic Updates with useOptimistic

A 900 ms round trip is fast for a database and slow for a checkbox. useOptimistic (useOptimistic) renders the outcome you expect while the action is in flight and drops it when the real data arrives — which in the App Router is a prop from a Server Component. The server owns the truth; the Hook owns the guess.

An optimistic checkbox (app/notes/note-list.js)JavaScript
export default function NoteList({ notes }) {
  const [shown, markDone] = useOptimistic(notes, (current, id) =>
    current.map((n) => (n.id === id ? { ...n, done: true, saving: true } : n)));
  function complete(id) {
    startTransition(async () => {
      markDone(id);                     // applied to `shown` immediately
      await toggleNote(id, true);       // the server catches up
    });
  }
  return shown.map((n) => (       // n.saving exists only on the optimistic copy
    <li key={n.id}><input type="checkbox" checked={n.done}
      onChange={() => complete(n.id)} /> {n.text}
      {n.saving && <em> saving...</em>}</li>));
}
The optimistic row, struck through while the action is still running
The optimistic row, struck through while the action is still running

The saving flag is the useful trick: it exists only in the optimistic copy, never in the server's data, so any row carrying it is unconfirmed by definition. When the action returns a fresh render of the route, notes changes, React 7,897 discards the optimistic state, and the row loses its label in the same commit — no second render, no cleanup code.

Three rules keep this honest. markDone must run inside a transition, which a form action supplies and an event handler does not. The reducer must be pure, because React replays it against the newest props. And a failed action makes the optimistic value vanish, so the old row snaps back silently, which looks like a bug: pair optimism with a visible failure path, and use it only where failure is rare and cheap to undo.