Undo with a toast
Delete optimistically, offer Undo for six seconds with a draining progress bar, and only send the real request when nothing took the deletion back. Built from Toast and Button.
Preview
Release checklist
Meeting notes
Launch copy
Deleted for real: 0
svelte
<script lang="ts">
import { Button } from "fancy-ui-svelte";
import { Toaster, toast, dismissToast } from "fancy-ui-svelte";
interface Note {
id: number;
name: string;
}
let notes = $state<Note[]>([
{ id: 1, name: "Release checklist" },
{ id: 2, name: "Meeting notes" },
{ id: 3, name: "Launch copy" },
]);
// Stand-in for the request that really deletes a note.
let committed = $state<string[]>([]);
function commitDelete(note: Note) {
committed = [...committed, note.name];
}
function remove(note: Note) {
const index = notes.findIndex((n) => n.id === note.id);
// Optimistic: the row leaves right away; nothing is sent yet.
notes = notes.filter((n) => n.id !== note.id);
const id = toast({
title: `Deleted "${note.name}"`,
duration: 6000,
progress: true,
action: {
label: "Undo",
onClick: () => {
notes = [...notes.slice(0, index), note, ...notes.slice(index)];
dismissToast(id);
},
},
// Undo is the only reason that must not commit; letting the toast
// time out, be dismissed or be pushed out by newer ones all do.
onClose: (reason) => {
if (reason !== "action") commitDelete(note);
},
});
}
</script>
<Toaster />
<div class="flex max-w-sm flex-col gap-2">
{#each notes as note (note.id)}
<div
class="border-border flex items-center justify-between rounded-lg border px-3 py-2 text-sm"
>
<span>{note.name}</span>
<Button size="sm" variant="outline" onclick={() => remove(note)}>Delete</Button>
</div>
{:else}
<p class="text-muted-foreground text-sm">
Nothing left. Undo from the toast to bring a note back.
</p>
{/each}
<p class="text-muted-foreground text-xs">Deleted for real: {committed.length}</p>
</div>Steps
- Remove the item from the list right away and raise a toast with `duration: 6000`, `progress: true` and an `Undo` action.
- In the action handler, restore the item at its old position and dismiss the toast. Pressing the action settles `onClose` with `action` before the handler runs.
- Commit the deletion from `onClose` for every reason except `action`: `timeout` (the time ran out), `dismiss` (the user closed it), and `evicted` (newer toasts pushed it out of the stack).
- Raise one toast per deletion. Each has its own timer and its own `onClose`, so two quick deletions are two independent undo windows.
Pitfalls
- Committing only on `timeout`: closing the toast or deleting four more items would then silently cancel a deletion the user confirmed by moving on.
- Committing on every reason including `action`: Undo would restore the item locally and still delete it on the server.
- Mounting the Toaster per page: when it unmounts, pending timers are dropped without firing `onClose`, so a deletion still waiting on its undo window is never committed. Mount one Toaster at the app level.
- Expecting hover to restart the clock: pausing keeps what was left, so hovering at 5.9 s of 6 s and leaving still gives the remaining 0.1 s, not a fresh window.