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:
- One hook forgets to cancel inflight queries; that one shows flicker
- Another hook restores the wrong key on error; that one strands rows
- A third hook updates label counts but not envelope rows; UI gets inconsistent
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?
- Star toggle, mark-read: mutate the row in place
- Archive, trash, spam, snooze: remove the row from the current view
- Label-as-folder, move: destructive in the current view;
onSettledinvalidation re-populates if appropriate
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:
- Email clients, task managers, kanban boards, photo apps
- Anywhere you have N "do something to selected items" actions over the same collection
- Anywhere a single user does many small actions per minute and round-trip latency would make a non-optimistic UI feel laggy
The signal that you've waited too long: you find yourself debugging cache desync after a mutation. Funnel first, debug later.
See also
- Same-Code-Path Preview — the daemon-side analogue: preview and execute share code paths
- Mxr — where the funnel lives (
apps/web/src/features/mailbox/useOptimisticMailMutation.ts)