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.
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.
Suppliers
12 active
Edit 3 suppliers
Same change across all three
Tier
Assign to
Suppliers
12 active
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
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.
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.
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.
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.
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.
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.
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.
Final composition
The whole SuppliersBulkEdit page, assembled. Table + toolbar + drawer + undoable commit + toast wiring.
Suppliers
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.
indeterminaterenders 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.
Drawerrenders withrole="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.