optimistic-update-rollback-contracts
Installation
SKILL.md
Optimistic update rollback contracts
Optimistic UI shows a mutation's result before the server confirms it, so every optimistic write owes three things: a snapshot taken before the change, a rollback to that exact snapshot on any failure, and a reconcile against true server state on settle. The bugs are never in the happy path — they surface as un-rolled-back fakes, duplicated rows, temp ids that outlive the response, and flicker when a refetch races the mutation.
Checklist (lead with the trap)
- Snapshot before you apply — and roll back to the snapshot, not by inverting the delta. Capture the prior value (
getQueryData, the prior list, prior form state) before the optimistic write and restore it on failure. Rolling back by re-subtracting/undoing the delta double-counts once a second mutation has touched the same value; restore the captured snapshot instead. - Cancel in-flight refetches before the optimistic write. In TanStack Query,
await cancelQueries({ queryKey })insideonMutate; otherwise a refetch already running resolves after your optimistic write and silently clobbers it (the docs call this out inline). - Wire rollback to every failure exit.
onErrorrestores the snapshot; amutationFn/promise that rejects (or throws before the request even leaves) with no rollback leaves the fake state permanently. SWR rolls back by default (rollbackOnError: true, and it accepts a per-error function), so confirm any error types you exempt (e.g.AbortError) are exempted on purpose. - Reconcile with server truth on settle — don't trust the optimistic value. Invalidate/refetch in
onSettledso server-derived fields (ids, timestamps, normalized or partially-rejected values) replace the guess. Return the invalidation promise if the mutation should stay pending until the refetch resolves. - Swap the temp client id for the server id — don't leave both. An optimistic insert uses a temp id (a uuid or
temp-prefix); when the response arrives, replace that entry, don't append the real one beside it, or a refetch shows the row twice. In Apollo, include__typename+ id so the normalized cache can merge temp to real, and use anupdatefn to place new objects into list queries. A later edit/delete still pointing at the temp id hits nothing. - Don't both write the result and invalidate the same query un-guarded. Writing the server result in
onSuccessand invalidating fires a redundant refetch that can flicker; pick one reconcile path per query (write-through, or invalidate-and-refetch). - Order concurrent mutations to the same resource. Mutations run in parallel by default; two writes to one record race and last-response-wins can corrupt state. Give related mutations a
scope: { id }(TanStack) so same-scope mutations run serially, or debounce/queue at the call site. - Know the concurrent-refetch window. If a second mutation starts while the first is still in flight there is nothing for it to cancel; when the first mutation's invalidation refetch resolves faster than the second mutation settles, the UI reverts (the "window of inconsistency"). Mitigate with query cancellation plus fine-grained invalidation — tag related mutations with a
mutationKeyand gate onisMutating. useOptimistic: pure reducer off the current base, and something must persist. The reducer must recompute from its current-state argument, not a captured stale list — React re-runs it if the base changes mid-transition (e.g. another client's insert lands). The value auto-reverts when the action/transition completes, so if no real mutation runs inside that transition it simply snaps back.
Quick probes
Treat hits as leads; confirm the snapshot to apply to rollback to reconcile path at the call site.