Skip to main content

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:

  1. Inverse operations. The command knows how to do the opposite (InsertCommand.undo() is a delete at the same range).
  2. 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 AddShape instance is executed twice with different stacks, you have a reuse bug. Either make commands single-use or document reusability.

How To Use It

  1. Extend the Command interface with undo().
  2. For each command, choose inverse or snapshot and implement both execute and undo.
  3. Introduce a History with two stacks (undo and redo) and three operations (run, undo, redo).
  4. On new run, clear the redo stack.
  5. Cap history size; consider coalescing repeated typing or movement commands into a single command.
  6. Test: run + undo + redo returns to the same state; undo past empty is a no-op; backups survive command replay.

Check Yourself

  1. When do you prefer an inverse operation over a snapshot, and vice versa?
  2. Why must a new action clear the redo stack?
  3. What is the risk of unbounded command history?
  4. 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:

  1. InsertCommand can undo (remove its inserted range).
  2. DeleteCommand snapshots the removed substring and restores it.
  3. Build a History with capped size 50.
  4. 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