Community JavaScript Snippet
useOptimisticMutation Hook With Rollback
My take on optimistic UI before React 19's `useOptimistic` was usable. The hook applies the change locally, fires the network call, and rolls back on failure with the original snapshot intact.
useOptimisticMutation Hook With Rollback
My take on optimistic UI before React 19's `useOptimistic` was usable. The hook applies the change locally, fires the network call, and rolls back on failure with the original snapshot intact.
By @laylabauer
January 28, 2026
·
Updated May 18, 2026
582 views
8
4.7 (9)
I always start optimistic UI with a state machine because the rollback logic gets ugly fast otherwise. Three fields matter: data (what the UI shows), snapshot (what we restore on error), and status (so the button can disable itself during pending). Capturing the snapshot at submit time, not at error time, is the key invariant: if a second mutation interleaves the response, we still know what to roll back to. A pure reducer like this can be tested with five lines of vitest and shared between hooks if you need an useOptimisticForm and an useOptimisticToggle later.
Two things in the hook are easy to get wrong, so I want them on the page. First, I stash mutationFn in a ref; if I closed over it directly, the mutate callback would only know about the function passed on first render and stale handlers would dial the wrong API. Second, mutate returns { ok, data } rather than throwing, because optimistic UI almost always wants to show a toast on failure rather than crash a render. I keep the useCallback deps empty on purpose: every state read goes through the functional updater, and every external value goes through the ref.
This is what the hook looks like at the call site: one line to wire it up, one event handler. The onClick await on mutate is critical when you need to chain a toast on failure, because the reducer transition happens before the promise resolves but the rollback message lives in the resolved value. In a real React render, after error fires, data would be the original { likes: 99 } again because the reducer copied it from snapshot. The user sees the optimistic increment, then the number rubber-bands back, and a toast explains why. That UX is the whole reason we built this.
If the user double-clicks faster than the network round trip, two mutations are in flight. Without a generation token, whichever response arrives last wins, even if it is the older one. The genRef is a monotonic counter that increments at submit; the snapshot table is a Map keyed by generation so the matching rollback is still available even if an older mutation fails after a newer one already succeeded. In production I have hit this exactly twice, both times during demo days where someone hammered a button. Adding the generation guard cost three lines and prevented a real lost-update bug.
