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.pop(); if (!c) return;
c.undo(); this.redoStack.push(c);
}
redo() {
const c = this.redoStack.pop(); if (!c) return;
c.execute(); 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. Most real editors cap size (100 actions), or dedupe repeats ("typing" coalesces into one command).
- 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.