Undo/Redo and Command History
What This Concept Is
Once actions are Command objects, undo becomes structural. Add an undo() method. Keep a stack of executed commands. To undo, pop the top command and call undo(). To redo, push it to a redo stack and call execute() again when asked.
Two strategies for making undo() work:
- Inverse operations. The command knows how to do the opposite (
InsertCommand.undo()is a delete at the same range). - State backup (memento). Before executing, the command saves enough receiver state to restore.
undo()restores the backup.
Use inverses when they are cheap and exact. Use backups when inverses are hard, lossy, or ambiguous.
Why It Matters Here
Undo is where casual Command use meets real engineering. You will find out quickly whether your commands actually capture enough to reverse themselves and whether your receiver is stable enough to be restored.
This is also the cluster where "the new action broke undo six versions ago" becomes a class of bug you know how to diagnose.
Concrete Example
A drawing app with undo via inverses where possible, snapshots elsewhere.
interface Command { execute(): void; undo(): void; label(): string; }
class Canvas {
shapes: string[] = [];
addShape(s: string) { this.shapes.push(s); }
removeLast() { this.shapes.pop(); }
snapshot(): string[] { return [...this.shapes]; }
restore(snap: string[]) { this.shapes = [...snap]; }
}
class AddShape implements Command {
constructor(private canvas: Canvas, private shape: string) {}
execute() { this.canvas.addShape(this.shape); }
undo() { this.canvas.removeLast(); } // inverse
label() { return `add ${this.shape}`; }
}
class Clear implements Command {
private backup: string[] = [];
constructor(private canvas: Canvas) {}
execute() { this.backup = this.canvas.snapshot(); this.canvas.shapes = []; }
undo() { this.canvas.restore(this.backup); } // snapshot restore
label() { return "clear"; }
}
class History {
private undoStack: Command[] = [];
private redoStack: Command[] = [];
run(cmd: Command) {
cmd.execute();
this.undoStack.push(cmd);
this.redoStack = []; // new action drops redo
}
undo() {
const c = this.undoStack[this.undoStack.length - 1]; if (!c) return;
c.undo(); this.undoStack.pop(); this.redoStack.push(c);
}
redo() {
const c = this.redoStack[this.redoStack.length - 1]; if (!c) return;
c.execute(); this.redoStack.pop(); this.undoStack.push(c);
}
}
const canvas = new Canvas();
const hist = new History();
hist.run(new AddShape(canvas, "circle"));
hist.run(new AddShape(canvas, "square"));
hist.run(new Clear(canvas));
hist.undo(); // restores circle, square
hist.undo(); // removes square
hist.redo(); // re-adds square
Notice the rule: a new run wipes the redo stack. That is standard editor semantics; document any deviation.
Common Confusion / Misconception
- "Commands with side effects are trivial to undo." They are not -- writing a file, sending an email, charging a card. If the side effect escapes, undo can only be "compensating action" (see Command Queues concept).
- Forgetting to reset the redo stack on new actions. After undo, if the user does anything new, redo should be cleared. Otherwise redo will replay into the wrong state.
- Infinite history. Command lists grow. Choose a documented retention budget or coalescing policy; there is no universal action count.
- Mutable shared state inside a command. If the same
AddShapeinstance is executed twice with different stacks, you have a reuse bug. Either make commands single-use or document reusability.
How To Use It
- Extend the
Commandinterface withundo(). - For each command, choose inverse or snapshot and implement both
executeandundo. - Introduce a
Historywith two stacks (undo and redo) and three operations (run,undo,redo). - On new
run, clear the redo stack. - Cap history size; consider coalescing repeated typing or movement commands into a single command.
- Test:
run+undo+redoreturns to the same state;undopast empty is a no-op; backups survive command replay.
Check Yourself
- When do you prefer an inverse operation over a snapshot, and vice versa?
- Why must a new action clear the redo stack?
- What is the risk of unbounded command history?
- What happens to undo if the receiver can change outside commands (another thread, an external signal)?
Mini Drill or Application
Extend the text editor from the previous concept so:
InsertCommandcan undo (remove its inserted range).DeleteCommandsnapshots the removed substring and restores it.- Build a
Historywith capped size 50. - Add a "macro" command that wraps a list and undoes them in reverse order.
Stop when a sequence of 10 mixed inserts and deletes followed by 10 undos returns the text to its original state.
Read This Only If Stuck
- Head First: More Uses of the Command Pattern -- Logging Requests (book index)
- GoF: Command Implementation (book index)
- GoF: Command Known Uses (book index)
- Head First: Memento (Leftover Patterns) (book index)
Contract and transfer
Moving a history entry only after its operation succeeds avoids losing the entry on an exception. It does not repair a receiver that mutated and then raised. The TypeScript example assumes its local mutations succeed, no outside edits, and a fresh command for each new action; it is not a transactional command engine. AddShape.undo removes the last shape, so an outside append invalidates its inverse.
L03 makes a smaller guarantee: replacement is computed from immutable strings after validating all arguments. Invalid edits leave text and both histories unchanged. Redo restores the previously computed result rather than consulting the clock or rerunning external work. Valid no-op edits are history entries under this contract and therefore also invalidate redo.
Trace: start 'a', append 'b', undo, invalid replacement, redo. The result must be 'ab'; clearing redo before validation would break it. A macro over local pure edits can compute a candidate document then commit once. A macro containing a network send needs compensation/recovery semantics; restoring local text cannot unsend the message. See the new Memento lesson and L03.