useOptimistic auto-reverts on failure — a hand-rolled optimistic hook doesn't

TIL why React's useOptimistic hook doesn't need explicit rollback code when a server call fails, while a manual optimistic-update hook built on plain useState does.

2 min read

Sabin Shrestha

Full-Stack Developer — Next.js, React & React Native

Today I learned why the optimistic-vote hook on Pollarise needed manual rollback code, and a hook built on useOptimistic wouldn't.

The original hook applies a vote to local state immediately, then reconciles with the server response:

useOptimisticVote.ts (original)
const [localTally, setLocalTally] = useState<Tally | null>(null);
 
const vote = async (optionId: string) => {
  setLocalTally((prev) => applyVote(prev, optionId));
  const confirmed = await api.vote(pollId, optionId); // throws on rejection
  setLocalTally(confirmed);
};

If api.vote rejects — a duplicate vote, a closed poll — localTally is stuck showing the optimistic count forever, because nothing ever runs to undo applyVote. Fixing that with plain useState means capturing a snapshot before the update and restoring it in a catch, and getting careful about a second vote landing before the first one's rollback fires.

useOptimistic avoids this by never storing the optimistic value as committed state:

useOptimisticVote.ts (with useOptimistic)
const [optimisticTally, setOptimisticTally] = useOptimistic(tally, applyVote);
 
const vote = (optionId: string) => {
  startTransition(async () => {
    setOptimisticTally(optionId);
    const confirmed = await api.vote(pollId, optionId);
    setTally(confirmed); // updates the real `tally` the hook is based on
  });
};

optimisticTally is computed from tally plus the pending transition on every render, not stored on its own. Once the transition settles — success or thrown error — React drops the optimistic value and optimisticTally falls back to tally. A failed vote just disappears; no snapshot, no catch block, no manual restore.

Tip

This only helps because the optimistic value is derived, not committed. If you need to show why a vote failed (not just revert it), you still need your own error state — useOptimistic erases the optimistic guess, it doesn't explain the rejection.