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.
Also known as: soft delete, undo toast.
Live demo
Section titled “Live demo”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.
| Item | Actions |
|---|
The markup
Section titled “The markup”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" type="button" data-hx-delete="/items/42" data-hx-target="closest tr" data-hx-swap="outerHTML" data-hx-disabled-elt="this">Delete</button> </td></tr>The tombstone
Section titled “The tombstone”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.
Grace period: server truth, toast hint
Section titled “Grace period: server truth, toast hint”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.
Server response contract
Section titled “Server response contract”| Request | Response |
|---|---|
DELETE /items/42 | 200 + the hidden tombstone (replaces the row via the button’s outerHTML swap) + the HX-Trigger undo toast — action.event carries the pairing key |
POST /items/42/restore (within grace) | 200 + the original row — it swaps back into the same slot; reusing the toast id updates the undo toast in place. Restore is idempotent |
POST /items/42/restore (grace expired) | 200 + the tombstone again + an error toast (“permanently deleted”) — 200-with-truth, no status-code choreography |
| No-JS delete | Keep a form fallback: method="post" + 303 back (the mutating-form branching); undo itself is toast-borne and honestly unavailable |
Undo or confirm?
Section titled “Undo or confirm?”| confirm-action | undo-delete | |
|---|---|---|
| Fits | rare, catastrophic, hard-to-reverse | frequent, recoverable |
| Cost | a dialog on every action | a grace window on the server |
| Failure mode | confirm fatigue | expired grace (error toast) |
Pick one per action — stacking both pays both costs and buys nothing.
Composition and degradation
Section titled “Composition and degradation”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).
Accessibility
Section titled “Accessibility”- The undo toast is
variant: "info"→role="status"(polite); the expiry failure isvariant: "error"→role="alert". Standard toast semantics, unchanged. - The Undo button is a real
<button>inside the toast — keyboard reachable while the toast is visible. Size the grace window to be comfortably longer than the toast, so a keyboard user who misses the button loses nothing they could not also lose by being slow. - The tombstone is
hiddenand never focusable; restoring swaps the row back without stealing focus. - The Delete button’s in-flight
disabled(data-hx-disabled-elt="this") is the native attribute, reported by assistive tech.
Related
Section titled “Related”- Confirm action recipe — the counterpart; the decision table above.
- Toast recipe — the action button and update-by-id this rides on.
- Request action recipe — the button shape.
- Datagrid bulk actions recipe — the 200-with-truth doctrine.