Build optimistic mutations with rollback you can trust
By the end of this recipe you'll have a useOptimistic-powered list where create / update / delete update the UI instantly, show a per-row "saving…" indicator, and roll back cleanly when the server says no — plus a clear rule for which actions should never be optimistic.
Overview
User clicks. The UI updates immediately with a pending row. The mutation fires in the background. On success: the pending row becomes real. On failure: the row rolls back, the error toasts, and the input that caused it gets focus back.
Without optimism, every action feels like submitting a form to a remote office. With optimism, the app feels like a notebook. The trick is doing it without lying to the user — the indicator has to be honest about state, the rollback has to be visible, and you have to know which actions should never be optimistic in the first place.
Optimistic for safe ops. Pessimistic for irreversible ones. Toggle a flag, like a post, mark a task done — optimistic. Send a payment, charge a card, fire a webhook — pessimistic, with a confirm. The decision is "can I un-press this button after the API fails" — if no, don't be optimistic.
The mechanism is two state slices: the committed list (the server's view) and the optimistic list (committed + pending intents). The UI always renders the optimistic list. On success, the pending intent merges into committed. On failure, the intent drops and an error toast fires. Concurrent edits are resolved by ordering — last write wins, but the toast tells the user which write was the loser.
Skip this recipe if your mutation is irreversible (payments, sent emails, deletes that cascade), or if the user can't usefully retry — e.g. an SMS gateway that already charged. Those want a confirm dialog and a real loading state.
What you'll build
Three states: pending, success, rollback.
Today
3 open · 2 done
Today
2 open · 3 done
Today
3 open · 2 done
Required pieces
Everything this recipe pulls from @corelithzw/react.
@corelithzw/react exports used here: useOptimistic, useToast, Button, RowCard, Stack. The hook is the headline export — everything else is rendering.
When NOT to be optimistic
| Operation | Optimistic? | Why |
|---|---|---|
| Toggle done / like / mark-read | YES | Idempotent, instantly reversible |
| Rename, edit a field | YES | Reversible — show old value on error |
| Reorder rows / drag-and-drop | YES | Rollback puts them back where they were |
| Create a record (POST) | YES* | *Pending row gets a tempId; replace on success |
| Soft-delete an item | YES | Undo toast for 5s, then commit |
| Hard-delete that cascades | NO | Server has to be source of truth — show a spinner |
| Send an email / SMS / push | NO | Can't un-send; needs a confirm |
| Charge a card / move money | NO | Irreversible side effects |
| Approve a workflow | NO | Triggers downstream; needs server ack |
Step-by-step build
Model the row with a state tag, not booleans
Every row is one of 'idle' | 'saving' | 'error'. Don't reach for isSaving + hasError booleans — they let you reach impossible combinations ("saving AND errored"). The tag makes the row a state machine.
Impossible states (saving + errored) don't compile.
Switch to the Code tab to see the snippet.
Wrap the list in useOptimistic
useOptimistic takes a committed list + a reducer for pending intents. The render reads from the optimistic projection; the commit handler is where you make the API call. The hook handles the merge and the rollback — your code is just two functions.
optimistic = committed + pending intents
Render reads optimistic. Server response writes committed.
Switch to the Code tab to see the snippet.
Wire create / update / delete with rollback
For create, the optimistic row uses a temp id (tmp_abc); the server returns a real id we swap in. For update / delete, the rollback is just don't apply: if the API fails, the committed list never changes, so the optimistic projection naturally drops the intent on the next render — and you fire a toast so the user knows.
Resolve concurrent edits with last-write-wins + a toast
Two operators on the same row will happen. The simple rule: server timestamps each commit and only accepts a write whose If-Match matches the row's updatedAt. If yours is stale, the API returns 409 — you reload the row from the server response and toast "your change was overwritten by Tendai".
Final composition
The whole useOptimisticList hook + a sample TasksList component. Toggle / create / rename / delete, rollback, concurrency, retry toast.
Variations
Three forks.
Optimistic with retry
Auto-retry on network failure with exponential backoff. Stop retrying after 3 attempts and toast the user.
const retry = async (fn: () => Promise<void>, n = 0) => {
try { await fn(); }
catch (e) {
if (n >= 3) throw e;
await new Promise(r => setTimeout(r, 2 ** n * 500));
return retry(fn, n + 1);
}
};
Optimistic queue (offline)
Hold pending intents in IndexedDB. When the connection comes back, drain the queue in order. Show a "5 pending changes" pill in the shell.
const queue = await idb.getAll('intents');
for (const i of queue) {
try { await commitIntent(i);
await idb.delete('intents', i.id); }
catch { break; } // stop on first failure
}
Soft-delete with Undo (transient toast)
Delete is optimistic + a short Undo. The API call doesn't fire until the toast fades. Use tone: 'transient' — it renders the new .toast.tone-transient variant from components.css: white surface, 1.5s fade after a 1.5s read window, action button (Undo) styled as the loudest element. See p-toast and Bulk edit with undo.
const remove = (id: string) => {
addIntent({ type: 'delete', id });
const t = setTimeout(() => commitDelete(id), 3000);
toast.show({
tone: 'transient', // .toast.tone-transient
title: 'Task removed',
action: { label: 'Undo',
onClick: () => { clearTimeout(t);
addIntent({ type: 'restore', id }); }},
});
};
Accessibility
What this recipe takes care of for you.
- Pending rows announce themselves. The "Saving…" indicator is wrapped in
role="status"+aria-live="polite", so a screen-reader user hears the state change without losing focus. - The row itself flags busy.
aria-busy={state.kind === 'saving'}on the row lets assistive tech know not to interrupt with the row's content while it's transient. - Errors come with a Retry. Every failure toast includes an
actionbutton — keyboard users tab to it, screen readers announce it as "Retry, button". - Disabled while pending. The checkbox is disabled during the saving state — clicking twice on a flaky network doesn't fire two requests, and a screen reader hears "checkbox, busy".
- Tempid rows don't lose focus. When the server returns the real id, the row's DOM node and key swap — but focus stays on whatever the user clicked next.
- Concurrency conflicts toast, don't dialog. A modal in the middle of typing is hostile. The toast tells the user what happened and offers a "Reload" action they can choose to take.
- Optimistic / pessimistic is decided up front. The table in "Required pieces" is part of the API — never lie to the user by being optimistic about a charge.