# 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.

- Docs: https://fancy-ui.rama.app/docs/recipes/undo-toast

## Components

- [Toast](https://fancy-ui.rama.app/docs/components/toast.md)
- [Button](https://fancy-ui.rama.app/docs/components/button.md)

## Steps

1. Remove the item from the list right away and raise a toast with `duration: 6000`, `progress: true` and an `Undo` action.
2. 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.
3. 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).
4. 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.

## Svelte

```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>
```

## React

```tsx
import { useState } from "react";
import { Button, Toaster, dismissToast, toast } from "fancy-ui-react";

interface Note {
	id: number;
	name: string;
}

const INITIAL: Note[] = [
	{ id: 1, name: "Release checklist" },
	{ id: 2, name: "Meeting notes" },
	{ id: 3, name: "Launch copy" },
];

export function UndoToastExample() {
	const [notes, setNotes] = useState<Note[]>(INITIAL);
	// Stand-in for the request that really deletes a note.
	const [committed, setCommitted] = useState<string[]>([]);

	function remove(note: Note, index: number) {
		// Optimistic: the row leaves right away; nothing is sent yet.
		setNotes((current) => current.filter((n) => n.id !== note.id));

		const id = toast({
			title: `Deleted "${note.name}"`,
			duration: 6000,
			progress: true,
			action: {
				label: "Undo",
				onClick: () => {
					setNotes((current) => [...current.slice(0, index), note, ...current.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") setCommitted((current) => [...current, note.name]);
			},
		});
	}

	return (
		<>
			<Toaster />
			<div className="flex max-w-sm flex-col gap-2">
				{notes.map((note, index) => (
					<div
						key={note.id}
						className="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, index)}>
							Delete
						</Button>
					</div>
				))}
				{notes.length === 0 ? (
					<p className="text-muted-foreground text-sm">
						Nothing left. Undo from the toast to bring a note back.
					</p>
				) : null}
				<p className="text-muted-foreground text-xs">Deleted for real: {committed.length}</p>
			</div>
		</>
	);
}
```

## Vue

```vue
<script setup lang="ts">
import { ref } from "vue";
import { Button, Toaster, dismissToast, toast } from "fancy-ui-vue";

interface Note {
	id: number;
	name: string;
}

const notes = ref<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.
const committed = ref<string[]>([]);

function remove(note: Note) {
	const index = notes.value.findIndex((n) => n.id === note.id);
	// Optimistic: the row leaves right away; nothing is sent yet.
	notes.value = notes.value.filter((n) => n.id !== note.id);

	const id = toast({
		title: `Deleted "${note.name}"`,
		duration: 6000,
		progress: true,
		action: {
			label: "Undo",
			onClick: () => {
				notes.value = [...notes.value.slice(0, index), note, ...notes.value.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") committed.value = [...committed.value, note.name];
		},
	});
}
</script>

<template>
	<Toaster />
	<div class="flex max-w-sm flex-col gap-2">
		<div
			v-for="note in notes"
			:key="note.id"
			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" @click="remove(note)">Delete</Button>
		</div>
		<p v-if="notes.length === 0" class="text-muted-foreground text-sm">
			Nothing left. Undo from the toast to bring a note back.
		</p>
		<p class="text-muted-foreground text-xs">Deleted for real: {{ committed.length }}</p>
	</div>
</template>
```
