Undo / Redo with Command Pattern
Reversible edits via a command stack — undo pops and reverses, redo re-applies.
UX PatternsAdvanced
Why
Destructive actions (delete, move) without undo force a confirm dialog that users dismiss reflexively. Undo-after-action is faster (no dialog) and safer (recoverable) — the Gmail pattern.
Nielsen Norman Group research shows users spend an average of 10-15 seconds on a page before leaving. Clear visual hierarchy and progressive disclosure improve task completion rates by 40-60%.
When
Use for document editors (text, canvas, form builders), list reordering, bulk operations, and any destructive action where a confirm dialog would slow the flow. Skip for one-off irreversible ops (payment, send email).
How
1. Command object — { do(): void, undo(): void } for each action; do applies, undo reverses
2. Stack — push the command after do(); undo() pops and calls command.undo(); redo() re-pushes
3. Coalesce — merge consecutive identical commands (5 char keystrokes → 1 'type "hello"' command)
4. Memory cap — keep last 50 commands; older edits are unrecoverable (avoid unbounded memory)
5. Shortcut — Cmd/Ctrl+Z undo, Cmd+Shift+Z redo; bind globally, not per-input
6. Snapshot alt — for complex state, store immutable snapshots instead of do/undo (simpler, more memory)
7. Label — show what will undo ("Undo delete 3 items") in a toast or menu item
Example
interface Command { do(): void; undo(): void; }
class History {
private stack: Command[] = [];
private redoStack: Command[] = [];
push(cmd: Command) { this.stack.push(cmd); this.redoStack = []; } // new action clears redo
undo() { const c = this.stack.pop(); if (c) { c.undo(); this.redoStack.push(c); } }
redo() { const c = this.redoStack.pop(); if (c) { c.do(); this.stack.push(c); } }
}
// Delete command — do removes, undo re-inserts
function deleteCmd(list: T[], idx: number): Command {
const removed = list[idx];
return {
do: () => { list.splice(idx, 1); },
undo: () => { list.splice(idx, 0, removed); },
};
}
// Usage
const history = new History();
const cmd = deleteCmd(items, 2);
cmd.do(); history.push(cmd); // delete item 2
history.undo(); // it's back
history.redo(); // deleted again
// Keyboard
useHotkey("mod+z", () => history.undo());
useHotkey("mod+shift+z", () => history.redo());
Common Problems
- ⚠No redo stack clear on new action — redo re-applies a stale command after a new edit
- ⚠Unbounded stack — 1000 edits in a canvas app, memory grows until crash
- ⚠Commands mutate shared state directly — undo reverses the wrong state if other edits happened in between
Best Practices
- ✓Clear the redo stack on every new action — redo only makes sense immediately after an undo
- ✓Cap the stack (50–100 commands) — older edits are unrecoverable, memory stays bounded
- ✓Show what will undo — 'Undo delete 3 items' in the toast/menu, users trust it more