← All notes
Frontend Development6 min

A manual undo is not a rollback

React 19's useOptimistic looks like a second piece of state you own. It is not. It is a temporary overlay on whatever base value you passed in, alive only while a transition or Server Action is pending. When that work settles — success or failure — React drops the overlay and re-renders from the base. If the base never changed, the UI snaps back on its own. Writing a second setOptimistic in a catch block to "undo" a toggle is solving a problem the hook already solved.

What the overlay actually is

You pass confirmed state — usually a Server Component prop or the result of the last successful action — into useOptimistic. The setter returned by the hook is meant to run inside a transition: a form action, or an explicit startTransition around the optimistic write and the async work. That boundary is what tells React how long the overlay should live.

Call the setter from a bare click handler and the console warns for a reason: without a transition, you have lost the guarantee that ties the temporary value to in-flight work. The UI may still flash the optimistic value, but the settle-and-drop contract is what you came for. Treat the warning as a bug report, not noise.

The catch-block undo that should not exist

The pattern we keep deleting from reviews looks like this: set the optimistic value, await the Server Action, and on failure call the setter again to flip the checkbox back. It appears to work. For a boolean toggle or a field that can be derived from the base prop alone, it is redundant. When the transition ends and the base is still false, React already renders false.

Worse, the manual flip fights the model. If a parent revalidation lands while you are mid-undo, or a second click queues another transition, you now have two writers inventing what the UI should show. The overlay is derived. The durable truth stays in the base. Keep them that way.

When the base cannot restore you

Automatic settle-and-drop is enough when the optimistic value is a transform of confirmed state: liked → unliked, archived → active, quantity 2 → 3. It is not enough when you inserted something the base list never contained — a comment with a temporary id, a draft row that only exists in the overlay.

There the reducer has to be pure and replay-safe: if the base list changes while the action is pending, React re-runs the reducer against the new base so your temp row stays on top of current data. Give optimistic inserts a stable temp id and a visible pending style. On failure, surface the error with useActionState (or an equivalent) after the overlay drops — silence after a snap-back is how people click again and create a second ghost row.

Success still has to update the base

A green Server Action that never revalidates is indistinguishable from a failure once the overlay clears: the checkbox flips, the request succeeds, and then the UI snaps back to the stale prop. That looks like a rollback bug. It is a cache bug. Pair optimistic writes with revalidatePath, revalidateTag, or a returned canonical value that lifts into the base before the transition ends.

Rapid clicks are a separate concern. Five toggles on the same row while the first action is in flight are not an optimistic-UI problem — they are a pending-guard problem. Disable or ignore further mutations for that id until the transition settles, the same way we disable Submit while a form action is pending. Per-row pending, not a whole-page spinner.

How we check it before it ships

Toggle once with the network throttled: the control should move immediately, then hold until the action settles. Force the action to reject: the control should return to the confirmed value without a second dispatch in your catch. Force it to succeed without revalidation once on purpose — confirm you see the false snap-back, then fix the cache path so you never ship that version.

Double-click the same control under latency and confirm only one mutation leaves the browser. For list inserts, kill the action mid-flight and confirm the temp row disappears and an error appears where the user is looking — not only in a toast they already dismissed. If you needed a manual undo to make the demo look right, the base and the overlay are still tangled.

Questions

Do I need to undo useOptimistic in a catch block?
Usually no. When the transition settles, React drops the optimistic overlay and re-renders from the base state. If the Server Action failed and the base never changed, the UI reverts on its own for toggles and other values derived from that base.
Why does a successful action still snap back?
The overlay only lasts until the transition ends. If success did not update the base — missing revalidatePath/Tag or no returned canonical value — React renders the stale confirmed state and it looks like a rollback. Fix the cache path, not the catch block.
When is a reducer required?
When you add items the base list does not contain, or when the base can change while a transition is pending. A pure reducer lets React replay your optimistic change on top of newer confirmed data instead of freezing an outdated copy.

Sources

  1. React docs — useOptimistic
  2. React docs — startTransition / Actions
  3. Next.js — Server Actions and revalidation

Have something to build?

Tell us what you're working on and we'll tell you honestly whether we're the right fit.

Work with us