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.
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 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.