Home/ Cookbook/ States/ Optimistic mutations

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.

States 2 primitives · 0 blocks · 0 patterns ~30 min React · @corelithzw/react

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.

Tasks

Today

3 open · 2 done

Pay supplier
done
Reconcile till
saving
Order toner
Call Tendai
01 Pending — UI ahead of the server
Tasks

Today

2 open · 3 done

Pay supplier
done
Reconcile till
done
Order toner
Call Tendai
02 Committed — server agreed
Tasks

Today

3 open · 2 done

Pay supplier
done
Reconcile till
Retry
Order toner
Call Tendai
03 Rollback — server said no, Retry offered

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

OperationOptimistic?Why
Toggle done / like / mark-readYESIdempotent, instantly reversible
Rename, edit a fieldYESReversible — show old value on error
Reorder rows / drag-and-dropYESRollback puts them back where they were
Create a record (POST)YES**Pending row gets a tempId; replace on success
Soft-delete an itemYESUndo toast for 5s, then commit
Hard-delete that cascadesNOServer has to be source of truth — show a spinner
Send an email / SMS / pushNOCan't un-send; needs a confirm
Charge a card / move moneyNOIrreversible side effects
Approve a workflowNOTriggers downstream; needs server ack

Step-by-step build

01

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.

Code-only step

Impossible states (saving + errored) don't compile.

Switch to the Code tab to see the snippet.

Step 1 · Row state@corelithzw/react
02

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.

Code-only step

optimistic = committed + pending intents

Render reads optimistic. Server response writes committed.

Switch to the Code tab to see the snippet.

Step 2 · useOptimistic
03

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.

Couldn't save that toggle
Retry
Step 3 · Commit / rollback
04

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".

Tendai edited this first. Their change kept: "Reconcile till — A".
Step 4 · Concurrency

Final composition

The whole useOptimisticList hook + a sample TasksList component. Toggle / create / rename / delete, rollback, concurrency, retry toast.

Pay supplier done
Reconcile till Saving…
Order toner Delete
TasksList.tsx@corelithzw/react

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 action button — 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.