Skip to content
Back to Design Hub

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

FAQ