Destructive Actions
Decide whether a destructive action needs a confirmation or an undo, and build the one it needs.
Deleting, removing, clearing and overwriting are the actions users regret. Every one of them needs a recovery path, and there are only two: stop the user before it happens, or let them take it back afterwards. This page decides which one an action gets, and shows how to build both.
Picking the wrong one is not a neutral mistake. A confirmation on a harmless action trains users to click through dialogs, which weakens every dialog in the product. An undo on an action the user cannot perceive announces a safety net that was never really there.
Note
The confirmation half of this page reflects patterns already in use across Reservix products. The undo half is new and has no product implementation yet. If your team is about to build one, get in touch so we can observe it.
Key principles
- Recoverable. Every destructive action offers either a way to stop or a way to take it back.
- Specific. The recovery path names the thing being destroyed, never "this item".
- Honest. Never promise a recovery the system cannot deliver.
- Singular. One safety net per action, not two.
When not to use
Check the action is destructive at all before giving it a recovery path. A confirmation on an action that destroys nothing spends the user's attention and weakens the dialogs that matter.
- The platform already asks. Replacing an uploaded file happens in the operating system's own file dialog, which the user can cancel before anything changes. The confirmation already exists there, and it is the platform's. Putting another one in front of it asks the same question twice, which is the never use both problem wearing a different hat.
- Nothing is lost. Closing a panel, collapsing a section or clearing a filter destroys no data. Let them happen.
- Redoing it costs a click. Removing a row from a selection, or deselecting a filter value, is recovered by repeating the action.
Choosing between confirmation and undo
For everything that is destructive, work through these four questions in order.
| Question | Why it matters |
|---|---|
| Is it a single item, rather than a bulk action? | Bulk multiplies the cost of a mistake by the size of the selection. Bulk always confirms. |
| Is the effect visible where the user is looking? | Undo depends on the user noticing inside a few seconds. An item they already scrolled past is not noticed at all. |
| Is it unmistakable which item was affected? | If the list reads as near-identical rows, the user cannot verify that the right one disappeared before the window closes. |
| Is a short delay safe, with no external side effect? | A notification, a payment or a webhook cannot be deferred ambiguously, and cannot be recalled once it is out. |
If all four answers are yes, the action can offer undo. The first "no" decides a confirmation.
The table has two tiers, and a few actions deserve a third. For a high-consequence irreversible delete, a whole account or thousands of recipients, the established escalation is to make the user type the name of the thing being deleted before the confirm button enables. It is deliberately high-friction, which is the point: it defeats the click-through habit exactly where clicking through costs most. Marigold has no component for this today, so build it in place and get in touch if your team needs one.
Notice that "is it reversible?" is not on the list. Undo in Marigold works by delaying the request rather than by reversing it, which makes almost anything reversible for a few seconds. Reversibility is therefore a property of the mechanism, not a test that can choose between mechanisms. It matters again only in the optimistic variant below.
Never use both
A confirmation followed by an undo toast is not double safety. It teaches users that dialogs are a formality to click past, and it costs them a second interaction for the same decision. Choose one, then report what happened with a plain feedback message that carries no action.
Confirming
Use the useConfirmation hook from <Dialog>. It opens an alertdialog, and resolves to confirmed or cancelled so the calling code reads top to bottom.
Uploaded files are the clearest confirmation case, and they fail on the third question above. Document uploads often show near-identical names distinguished by a date, so a user who removes the wrong one cannot tell within the undo window. Files also live inside forms, and a user who leaves the form mid-window would leave a deferred deletion that never runs.
<FileField> gates its own remove buttons through onBeforeRemove. Return false to keep the file, so a confirmation reads as one expression. Upload two files with similar names to see why this one confirms rather than offering undo.
Drop files here
import { FileField, useConfirmation } from '@marigold/components';export default () => { const confirm = useConfirmation(); return ( <FileField label="Documents" multiple // [!code highlight:10] onBeforeRemove={async file => { const result = await confirm({ variant: 'destructive', title: 'Delete file?', content: `“${file.name}” will be deleted permanently. This cannot be undone.`, confirmationLabel: 'Delete file', }); return result === 'confirmed'; }} /> );};What the dialog owes the user:
- Name the object. "Delete file?" with the filename in the body, not "Are you sure?".
- State the consequence plainly. Say that it cannot be undone, and say what else goes with it.
- Repeat the verb in the confirm button. "Delete file" rather than "OK", so the button reads as the decision it is.
- Leave the safe path as the default. The
destructivevariant focuses Cancel on open, so a reflexive Enter cancels. PassautoFocusButton: 'action'to focus the confirm button instead.
Dismissing the dialog with Escape resolves as cancelled. A press outside does not dismiss a confirmation at all, which is deliberate: a stray click next to the dialog should not decide anything. Never treat a dismissal as consent, and do not block Escape to prevent mis-clicks: an alertdialog the keyboard cannot escape is an accessibility defect, not a safety feature.
Do
Name the object in the title or body, and repeat the verb in the confirm button so the decision is readable from the button alone.
Don't
Don't ask "Are you sure?" with OK and Cancel. It carries no information about what is about to happen.
Offering undo
An undo toast reports the action as done, hides its effect immediately, and sends the real request only when the toast closes. Until then the work is pending and a single press takes it back.
Reporting the deletion before it has happened is still honest, because the toast's own lifecycle guarantees it: every way a toast can close ends with the request going out. That guarantee is what the helper below is for, and it is why a commit driven by your own timer is not merely fragile but dishonest.
Mailing list | Recipients | Actions |
|---|---|---|
Newsletter August | 1240 | |
Jazz Night Reminder | 312 | |
Season Preview | 878 |
import { useState } from 'react';import { Button, EmptyState, Inline, Stack, Table, ToastProvider, useToast,} from '@marigold/components';import { RotateCcw, Trash2 } from '@marigold/icons';interface MailingList { id: string; name: string; recipients: number;}const initialLists: MailingList[] = [ { id: '1', name: 'Newsletter August', recipients: 1240 }, { id: '2', name: 'Jazz Night Reminder', recipients: 312 }, { id: '3', name: 'Season Preview', recipients: 878 },];const Lists = () => { // `pending` rows are hidden but not deleted yet, so undo restores in place. const [lists, setLists] = useState(initialLists); const [pending, setPending] = useState<string[]>([]); const { addUndoToast } = useToast(); const visible = lists.filter(list => !pending.includes(list.id)); const commit = (id: string) => { setLists(current => current.filter(list => list.id !== id)); setPending(current => current.filter(pendingId => pendingId !== id)); }; const restore = (id: string) => setPending(current => current.filter(pendingId => pendingId !== id)); const deleteList = (list: MailingList) => { setPending(current => [...current, list.id]); // [!code highlight:5] addUndoToast({ title: `“${list.name}” deleted`, onUndo: () => restore(list.id), onCommit: () => commit(list.id), }); }; return ( <Stack space={2}> <Table aria-label="Mailing lists" size="compact"> <Table.Header> <Table.Column id="name" rowHeader> Mailing list </Table.Column> <Table.Column id="recipients">Recipients</Table.Column> <Table.Column id="actions" alignX="right"> Actions </Table.Column> </Table.Header> <Table.Body emptyState={() => ( <EmptyState title="No mailing lists" description="Every list has been deleted. The last deletion committed when its toast closed." /> )} > {visible.map(list => ( <Table.Row id={list.id} key={list.id}> <Table.Cell>{list.name}</Table.Cell> <Table.Cell>{list.recipients}</Table.Cell> <Table.Cell> <Button variant="destructive-ghost" size="small" aria-label={`Delete ${list.name}`} onPress={() => deleteList(list)} > <Trash2 size={16} /> </Button> </Table.Cell> </Table.Row> ))} </Table.Body> </Table> {lists.length < initialLists.length && ( <Inline alignX="right"> <Button variant="ghost" size="small" onPress={() => { setLists(initialLists); setPending([]); }} > <RotateCcw size={16} /> Reset demo </Button> </Inline> )} </Stack> );};export default () => ( <> <ToastProvider position="bottom-right" /> <Lists /> </>);Use addUndoToast from useToast. It owns the timing, so the two things you still own are the two that belong to your data:
- Hide the effect straight away so the interface responds at once. Keep the committed data separate from what is hidden, so
onUndoputs the row back where it was. - Send the request from
onCommit. It runs when the window closes without an undo, and only then.
Both are in the demo above, on the highlighted lines.
The window is 5000ms unless you pass timeout, and that is also a floor: smaller values are clamped up to it, and there is no ceiling. Between five and ten seconds is the common range, so pass a value when the decision needs longer.
Why the helper exists
Assembled by hand, this pattern has three ways to lose data quietly, and addUndoToast closes all three.
A separate timer drifts. The obvious implementation pairs the toast with a setTimeout of the same length. React Aria pauses a toast's timer while the toast region is hovered or focused, so that users get time to read it. A separate timer does not pause. The moment a user moves the pointer towards the Undo button the two clocks separate: the toast stays on screen looking live, and the deletion commits underneath it. Pressing Undo then does nothing. Driving the commit from the toast's own onClose keeps "the toast is gone" and "the work is committed" the same event.
An unguarded commit runs after an undo. onClose fires however the toast goes away, including on the way out of an undo, so the commit has to know that the undo already happened.
A toast that never closes never commits. variant="warning", variant="error" and timeout: 0 all persist until dismissed, so a commit riding onClose waits indefinitely while the interface reports the deletion as done. addUndoToast has no variant and treats timeout: 0 as the default window for exactly this reason.
It also drops the toast's close button. Undo and Close side by side is a trap: to most people a close button reads as "get this out of my way", not "go through with it", yet closing is what puts the deletion through. With no close button the only two ways out are Undo and the window running out, and both mean what they look like.
Do
Use addUndoToast and give it onUndo and onCommit. Pass timeout
when the decision needs longer than the default five seconds.
Don't
Don't build the toast by hand with addToast and a setTimeout, and
don't reach for variant="warning", variant="error" or timeout: 0.
None of them auto-dismiss, so the commit never runs on its own.
What undo cannot cover
Deferring the request buys the undo window, and it costs these:
- Leaving the page abandons the commit. Close the tab during the window and the request never goes out, while the interface already reported the deletion. Prefer a confirmation wherever the user is likely to navigate away, such as inside a form.
- Clearing the queue commits everything at once.
clearToastsruns every close handler, so a global clear puts through every deferred deletion rather than dropping it. That is the safe direction because the interface has already reported each of those deletions: dropping them instead would leave the user looking at a list that quietly grew its rows back. It is still a decision the user did not make one item at a time. - The window is a time limit. An auto-committing timer puts this pattern inside WCAG 2.2.1 Timing Adjustable, which asks that a time limit can be extended. The toast's timer pauses while the toast region is hovered or focused, and the region is a landmark reachable with F6, so the extension exists, but it is not discoverable. Five seconds is not enough to hear a toast, find the Undo button and press it, so undo has an accessibility floor that a confirmation does not. Where the user may not be watching, let the four questions above fall towards a confirmation.
When your API can restore
If the operation is genuinely reversible on the server, prefer sending the request immediately and having Undo call the restore endpoint. It survives navigation, it needs no window, and it reports the truth. Use the deferred form above only when the underlying operation cannot be taken back, which in practice is most of the time.
Fetching and mutations has a working version of exactly this: confirm, remove optimistically, name the row in the toast, roll back on error, invalidate on settle.
Content guidelines
Toasts follow the feedback message guidelines. Two rules matter here in particular.
Name what happened to what. "Newsletter August deleted" tells a user who mis-clicked exactly what they lost, and "Item deleted" does not. For bulk actions, carry the count instead, as described in Bulk Actions.
Label the recovery with the verb, not the outcome. "Undo" is understood. "Restore", "Revert" and "Cancel" all invite a second reading, and "Cancel" is actively ambiguous next to a completed action.
Related
- Dialog for the confirmation component and the
useConfirmationhook. - Fetching and Mutations for wiring a confirmed deletion to a server mutation, with optimistic removal and rollback.
- Feedback Messages for choosing a message type and writing its copy.
- Bulk Actions for confirming an action across a selection.
- Button for the
destructiveanddestructive-ghostvariants. - Panel for grouping irreversible actions into a danger zone.