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[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 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​

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.