Optimistic Mutation Funnel

Optimistic mutation funnel

In an app with many similar mutations (archive, trash, star, label, snooze, mark-read), the temptation is to write one mutation hook per action. Don't. Funnel them through one primitive that owns snapshot, optimistic update, rollback on error, and cache invalidation. Each action contributes only its name and its projection.

The shape

In TanStack Query, one hook (useOptimisticMailMutation in mxr's web app):

onMutate: cancel inflight queries, snapshot envelope cache, apply projection
onError:  restore from snapshot, toast error
onSuccess: if response has mutation_id and action is undoable, show 60s Undo toast
onSettled: invalidate envelope and label queries

Every mailbox mutation funnels through this. Archive, trash, spam, star, mark-read, label add/remove, move, snooze, unsubscribe, read-and-archive — all one hook, parameterized by a MailAction discriminated union and a mapMailboxRows projection function.

Why funnel rather than copy

When you write per-action hooks, the snapshot/restore logic gets copy-pasted. Within a sprint it diverges:

The bugs are individually small but the rate of regression is high because every new mutation is another chance to forget one of the steps. Centralize the steps; let the divergence be in projection only.

Projection rules are the only per-action knowledge

Each action declares one thing: what should this row look like after the mutation, in the current view's cache?

That's the whole per-action customization. Everything else — snapshot, restore, error toast, undo affordance, invalidation keys — is the funnel's job.

Undo is a property of the funnel, not the action

The bridge returns mutation_id for undoable actions. The funnel shows a 60-second toast with an Undo button that calls a reverse endpoint with that ID. Adding a new undoable action doesn't add undo plumbing; it just needs the bridge to issue a mutation_id. The UI side already knows what to do.

The invariant this protects

Optimistic UIs are fragile. The optimistic view, the server response, and the rollback have to agree about what the row looked like before and what it should look like after. A funnel lets you reason about that invariant in one place rather than M places.

Where this generalizes

Any React/Vue/Solid app with optimistic mutations on a list-shaped resource:

The signal that you've waited too long: you find yourself debugging cache desync after a mutation. Funnel first, debug later.

See also