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)

  1. 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.
  2. Cancel in-flight refetches before the optimistic write. In TanStack Query, await cancelQueries({ queryKey }) inside onMutate; otherwise a refetch already running resolves after your optimistic write and silently clobbers it (the docs call this out inline).
  3. Wire rollback to every failure exit. onError restores the snapshot; a mutationFn/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.
  4. Reconcile with server truth on settle — don't trust the optimistic value. Invalidate/refetch in onSettled so 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.
  5. 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 an update fn to place new objects into list queries. A later edit/delete still pointing at the temp id hits nothing.
  6. Don't both write the result and invalidate the same query un-guarded. Writing the server result in onSuccess and invalidating fires a redundant refetch that can flicker; pick one reconcile path per query (write-through, or invalidate-and-refetch).
  7. 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.
  8. 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 mutationKey and gate on isMutating.
  9. 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.

Installs
1
GitHub Stars
1
First Seen
3 days ago
optimistic-update-rollback-contracts — voidmatcha/frontend-niche-skills