A UI Without Undo Is Asking Users to Trust You Too Much
Undo is not a nice-to-have feature. For any operation that modifies or deletes data, undo is a basic contract with the user.
A delete button with no undo is asking the user to be certain before they click. Most users are not certain — they are making their best guess and relying on you to catch their mistakes. Every destructive operation without a recovery path is a silent tax on user confidence. Gmail's "Undo Send," Notion's trash, Linear's soft-delete — these are not features built because users asked. They are built because the alternative is losing trust permanently the first time a user deletes something they needed.
Which Operations Need Undo
Not everything needs undo. Changing a toggle, updating a preference, switching a tab — these are reversible by doing the opposite. The operations that genuinely need undo are the ones where the reverse operation is not obvious or not available:
- Delete (single item or bulk): The item is gone and the user has to remember what was in it to recreate it.
- Bulk changes: Selecting 40 items and marking them all done, then realizing some were not done.
- Form reset: Clearing a form with a large number of fields in progress.
- Archive: Similar to delete from the user's perspective, even if reversible technically.
- Destructive merges: Combining two records where the pre-merge state is lost.
The test: if the user says "wait, no" half a second after the action, can they get back to where they were? If not, you need undo.
The Toast-with-Undo Pattern
The most practical implementation for most product work is soft-delete with an undo toast. The item is removed from the UI immediately (optimistic update), a toast appears for several seconds with an undo action, and the actual deletion fires either when the toast expires or when the user navigates away.
This pattern gives users the perception of speed (the item disappears instantly) with the safety net of recovery (they can reverse within the window). The interface should tell you what happened — the toast is how you do that for destructive actions.
type HistoryEntry<T> = { action: "delete"; item: T; timestamp: number };
function useUndoableDelete<T extends { id: string }>(
onDelete: (id: string) => Promise<void>
) {
const [items, setItems] = useState<T[]>([]);
const [history, setHistory] = useState<HistoryEntry<T>[]>([]);
const pendingDeletes = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
function remove(item: T) {
// Optimistic: remove from UI immediately
setItems((prev) => prev.filter((i) => i.id !== item.id));
setHistory((prev) => [...prev, { action: "delete", item, timestamp: Date.now() }]);
// Schedule actual deletion
const timer = setTimeout(async () => {
await onDelete(item.id);
pendingDeletes.current.delete(item.id);
setHistory((prev) => prev.filter((h) => h.item.id !== item.id));
}, 5000); // 5s undo window
pendingDeletes.current.set(item.id, timer);
}
function undo(itemId: string) {
const timer = pendingDeletes.current.get(itemId);
if (timer) {
clearTimeout(timer);
pendingDeletes.current.delete(itemId);
}
const entry = history.find((h) => h.item.id === itemId);
if (entry) {
setItems((prev) => [...prev, entry.item]);
setHistory((prev) => prev.filter((h) => h.item.id !== itemId));
}
}
return { items, setItems, remove, undo, pendingHistory: history };
}
The pendingDeletes ref holds the timers. The actual API call only fires when the timer expires — meaning undo() cancels both the timer and the deletion. The UI reflects the post-delete state immediately.
Simple History Stack with useReducer
For forms or editors that need multi-level undo (not just a single action), a history stack is more appropriate. The pattern: every state change pushes to a stack; undo pops back one.
type HistoryState<T> = {
past: T[];
present: T;
future: T[];
};
type HistoryAction<T> =
| { type: "SET"; value: T }
| { type: "UNDO" }
| { type: "REDO" };
function historyReducer<T>(
state: HistoryState<T>,
action: HistoryAction<T>
): HistoryState<T> {
switch (action.type) {
case "SET":
return {
past: [...state.past, state.present],
present: action.value,
future: [],
};
case "UNDO": {
if (state.past.length === 0) return state;
const previous = state.past[state.past.length - 1];
return {
past: state.past.slice(0, -1),
present: previous,
future: [state.present, ...state.future],
};
}
case "REDO": {
if (state.future.length === 0) return state;
const next = state.future[0];
return {
past: [...state.past, state.present],
present: next,
future: state.future.slice(1),
};
}
}
}
This is the same pattern that Zustand's temporal middleware and Valtio's snapshot system are built on. The core structure is always a past stack, a present value, and a future stack that clears on new actions.
Optimistic UI and Undo Are the Same Concern
Optimistic UI assumes the operation will succeed and rolls back on failure. Undo assumes the operation succeeded and offers a voluntary rollback. Both require the same thing: holding the pre-action state long enough to restore it.
When you implement optimistic updates, you already have the prior state available. Adding undo is then mostly a UI decision — show a toast with an undo action, connect it to the rollback function you wrote for the error case. The machinery is the same. The only difference is that undo is user-initiated rather than error-initiated.
Building undo into your delete flows from the start is easier than retrofitting it. The moment you start treating rollback as a first-class path alongside the success path, the implementation becomes natural.