Skip to content

Undo delete

undo-delete is the blessed undo instead of confirm pattern for frequent destructive actions: no dialog — the delete executes immediately, the server keeps the item recoverable for a grace period, and the result toast carries an Undo button that puts the row back exactly where it was. It is the counterpart to confirm-action, built from already-shipped pieces with zero new JavaScript, and stable under the markup versioning policy.

It needs installToast() (in the auto-init ./behaviors bundle) — nothing else.

Click Delete — no confirmation — then Undo in the toast within 10 seconds to put the row back. The server keeps a 30-second grace window (threaded statelessly through the tombstone’s restore URL); after it expires the restore comes back as a “Too late” error toast. The endpoints live under api/recipes/undo-delete/; Reset rows refetches the canned rows.

ItemActions

One button per item, in the request-action shape — deliberately without data-hc-confirm:

<tr id="item-42">
<td>Anvil</td>
<td>
<button class="hc-button" data-size="sm"
data-hx-delete="/items/42"
data-hx-target="closest tr"
data-hx-swap="outerHTML"
data-hx-disabled-elt="this">Delete</button>
</td>
</tr>

The DELETE response replaces the row with a hidden tombstone that preserves the DOM slot and carries the restore wiring, and triggers the undo toast:

<tr id="item-42" hidden
data-hx-post="/items/42/restore"
data-hx-trigger="item-42:restore from:body"
data-hx-swap="outerHTML"></tr>
HX-Trigger: {"hc:toast":{"id":"undo-item-42","message":"\"Anvil\" deleted",
"variant":"info","duration":10000,
"action":{"label":"Undo","event":"item-42:restore"}}}

The pairing key (item-42:restore) is one server-generated string in exactly two places: the toast’s action.event and the tombstone’s trigger. Clicking Undo makes the toast dispatch that bubbling event (the shipped toast action button); the tombstone hears it via from:body and POSTs the restore; the server returns the original row, which swaps back into the same slot — position preserved, several pending undos never cross.

The restore response reuses the toast id, so the undo toast updates in place to “restored”. Use <tr hidden> in tables, <li hidden> in lists, <div hidden> in card grids.

The server hard-deletes on its own clock (recommended: grace ≥ toast duration, e.g. 10 s toast / 30–60 s grace). A dismissed or expired toast finalizes nothing. Restore after expiry follows the 200-with-truth doctrine: body = the tombstone again, plus an error toast (“permanently deleted”) — no status-code choreography, same as datagrid-bulk-actions.

confirm-actionundo-delete
Fitsrare, catastrophic, hard-to-reversefrequent, recoverable
Costa dialog on every actiona grace window on the server
Failure modeconfirm fatigueexpired grace (error toast)

Pick one per action — stacking both pays both costs and buys nothing.

Inside a datagrid the tombstone swaps in as a row and the tbody observer re-derives roles, offsets and selection on both delete and restore. Tombstones are inert leftovers pruned by any full re-render (a data-region refresh, pagination). Without JavaScript, keep a form-fallback delete (method="post" + 303, the mutating-form branching) — undo itself is toast-borne and honestly unavailable; give no-JS deletes a server-side confirm page if they need protection.

The claims here — position-preserving restore, pairing-key isolation across two pending undos, the expiry path — are pinned by a real-htmx browser test (test-browser/undo-delete.spec.mjs).