Home/ Cookbook/ Forms/ Bulk edit with undo

Build a bulk-edit flow where Undo is the only confirmation

By the end of this recipe you'll have a multi-select table, a bulk-edit drawer that changes tag / assign-to / status across N rows, an optimistic UI update, and a five-second "Undo" toast that cancels the API call if the user changes their mind — no "Are you sure?" dialog in sight.

Forms 3 primitives · 1 block · 2 patterns ~30 min React · @corelithzw/react

Overview

Select rows → open the bulk-edit drawer → set the new value → save. The save isn't real for five seconds: the user sees the optimistic update and an Undo toast. If they tap Undo, nothing was sent; otherwise the API call fires.

Confirmation dialogs are a tax on power users. "Are you sure you want to assign 12 items to Tendai?" — yes, that's why I clicked Save. The fix is to invert the trust model: assume the action is correct, show it immediately, and give the user a five-second window to take it back. This works for any operation that's reversible client-side; it's how Gmail's archive, Linear's bulk-edit, and every modern productivity tool handles destructive-looking actions.

Undo > confirm dialog, always. Confirms break flow; undos respect it. The 5-second window is hardcoded — recipes are opinionated. Long enough to catch a click-through, short enough to keep the API in sync.

The mechanism is a timer. The save call is wrapped in a setTimeout; Undo clears the timer. The optimistic UI is local state mutated immediately; the rollback is the local state stashed before the optimistic write. No queue, no offline sync — just clearTimeout + setState(prev).

Skip this recipe if the action is irreversible — payments, deletes that cascade, anything that sends an email. Those still want a confirm. The Undo pattern is for reversible field edits.

What you'll build

Three states: nothing selected, drawer open with 3 rows selected, and the post-save Undo toast.

Huchu · Suppliers

Suppliers

12 active

Acme MetalsTier A
Mukamba MillsTier B
Sibanda Co.Tier B
Harare SparesTier C
GoldfieldTier B
3 selectedEdit
01 Select 3 → toolbar appears
Huchu · Suppliers

Edit 3 suppliers

Same change across all three

Tier

Set tier to

Assign to

Account manager
Save 3 changes
02 Drawer — fields that apply to all
Huchu · Suppliers

Suppliers

12 active

Acme MetalsTier A
Mukamba MillsTier A
Sibanda Co.Tier A
Harare SparesTier C
3 suppliers updated
4sUndo
03 Optimistic + Undo (5s)

Required pieces

Everything this recipe pulls from @corelithzw/react.

@corelithzw/react exports used here: Stack, Drawer, BottomSheet, Form, Field, Select, Button, Checkbox, useToast, RowCard. The undo logic lives in a tiny useUndoableCommit hook.

Step-by-step build

01

Model selection as a Set, not an array of full rows

The selection only stores row ids. Looking up the rows themselves happens at render time. That way bulk-editing a 200-row page doesn't re-serialise 200 row objects when the user ticks a checkbox.

Code-only step

selected derives the row objects at render time.

toggleAll is "select all visible", not all server-side.

Switch to the Code tab to see the snippet.

Step 1 · Selection state@corelithzw/react
02

Build the undoable-commit hook — a setTimeout the user can cancel

The hook stashes the previous state, applies the optimistic update, and schedules the API call in a 5-second timer. Undo clears the timer and restores the stash. No queue, no offline sync — keep the surface area tiny.

Code-only step

If the API rejects, we roll the UI back without scaring the user.

No queue, no localStorage. The whole thing fits in 30 lines.

Switch to the Code tab to see the snippet.

Step 2 · Undoable commit
03

Render the bulk-edit drawer — fields that say "apply to all N"

The drawer is a partial form: every field is optional, and the heading says "Edit 3 suppliers" so the user knows the scope. Leaving a field blank means "don't touch this field on any row" — the patch only sends keys with values.

Edit 3 suppliers

Only the fields you change will be applied.

Tier
Tier A
Account manager
— don't change —
Cancel
Save 3 changes
Step 3 · Bulk-edit drawer
04

Wire optimistic + Undo — the timer is the contract

On Save: stash prevRows, apply the patch to local state, schedule the API call, fire a toast with an Undo button. The toast's countdown is decorative; the real clock is the timer inside the hook. Undo clears it; otherwise the call goes.

Code-only step

Undo = clearTimeout + setRows(prev). That's it.

The toast duration and the commit timer share the same 5s number.

Switch to the Code tab to see the snippet.

Step 4 · Wire it up

Final composition

The whole SuppliersBulkEdit page, assembled. Table + toolbar + drawer + undoable commit + toast wiring.

Suppliers

Acme MetalsTier A
Mukamba MillsTier A
Sibanda Co.Tier A
3 suppliers updated
4sUndo
SuppliersBulkEdit.tsx@corelithzw/react

Variations

Three common forks. Each is a small diff from the final composition.

Single-row edit, same hook

The hook works for one row too. Inline-edit a single cell, schedule the commit, surface Undo.

const ctrl = makeUndoableCommit({
  onCommit: () => api.patchRow(row.id, patch),
  onRollback: () => setRow(prev),
});
ctrl.schedule();
push({ ..., action: { label: 'Undo', run: ctrl.undo } });

Stacked undos

Two rapid edits should each be undoable. Track each commit by id; the toast lists "Edit, Edit · undo last".

// Track pending commits in a Map
const pending = useRef(new Map());
function schedule(id, ctrl) {
  pending.current.set(id, ctrl);
  setTimeout(() => pending.current.delete(id), UNDO_MS);
}
const undoLast = () => {
  const [id, ctrl] = [...pending.current].pop();
  ctrl.undo();
};

Server-confirmed bulk progress

If the bulk operation is long-running (200 rows), don't schedule a single commit — fire immediately and show progress in the toast.

// Drop UNDO_MS, fire now
const { progress } = useBulkJob(api.start(ids, patch));
push({
  tone: 'info',
  title: `Updating ${ids.size}…`,
  description: `${progress}%`,
  persistent: true,
});

Accessibility

What this recipe takes care of for you.

  • Each row checkbox is labelled. aria-label="Select Acme Metals" — screen readers say the supplier's name on every checkbox.
  • The "select all" checkbox knows when it's mixed. indeterminate renders a dash glyph and reads as "mixed" to screen readers.
  • The selection toolbar is a toolbar. role="toolbar" + aria-label="Bulk actions". The 3 / N count lives inside it as a live status.
  • The drawer is a dialog. Drawer renders with role="dialog", traps focus, returns focus to the "Edit" button on close.
  • The Undo toast is assertive. aria-live="assertive" so screen-reader users hear the announcement and the Undo affordance immediately — they have the same 5 seconds as sighted users.
  • The countdown is visual only. The seconds tick down on screen, but the announcement names the window once ("5 seconds to undo") rather than re-announcing every second.
  • The drawer's "don't change" is a real option. Not a placeholder. Selecting it via the keyboard is the only way to clear a field; we don't rely on text-input clearing.