Add undo to an editor.
The instinct is to snapshot the document before every change and keep the snapshots. For a text editor with a large document that is megabytes per keystroke, and it tells you nothing about what changed — so you cannot show “Undo Bold”, only “Undo”.
The pattern
Make each action an object that knows how to do itself and how to take itself back.
interface Command { label: string; execute(): void; undo(): void;}
class SetBold implements Command { label = 'Bold'; private previous = false;
constructor(private readonly range: Range) {}
execute() { this.previous = this.range.bold; // remember just enough to reverse it this.range.bold = true; }
undo() { this.range.bold = this.previous; }}The history is then a list of these, and the whole undo system is two stacks:
class History { private done: Command[] = []; private undone: Command[] = [];
run(command: Command) { command.execute(); this.done.push(command); this.undone = []; // a new action invalidates the redo branch }
undo() { const c = this.done.pop(); if (c) { c.undo(); this.undone.push(c); } } redo() { const c = this.undone.pop(); if (c) { c.execute(); this.done.push(c); } }}Try it — note what happens to the redo stack when you act after undoing:
·
- history (undo stack)
- empty
- undone (redo stack)
- empty
Each command stores its own reversal
SetBold records previous during execute, not during construction. That
matters: the same command object might be re-executed by redo, at which point
the state to restore may be different.
This is the real design work in Command, and it has a name — every command must capture exactly enough state to reverse itself, and no more. Store too little and undo is wrong; store the whole document and you are back to snapshots.
What else falls out of it
Once a request is an object, other things become easy that were not:
Queue it. A command can be executed later, or on another thread. Every job queue payload is a serialised command.
Log it. Write commands to a file as they run and you can replay them to reconstruct state. That is event sourcing, and it is the same idea a database’s write-ahead log uses.
Send it. A serialisable command can cross a network. Multiplayer editors send commands, not documents, because commands are small and can be transformed against each other.
Batch it. A MacroCommand holding a list of commands, executing them in
order and undoing them in reverse, is itself a Command — that is
Composite applied to behaviour.
class Macro implements Command { constructor(private readonly parts: Command[]) {} execute() { for (const p of this.parts) p.execute(); } undo() { for (const p of [...this.parts].reverse()) p.undo(); }}The reverse() is not decoration. Undoing in the same order would restore
earlier state on top of later state.
The lighter version
If you do not need undo, a command is a function, and the pattern is Strategy:
button.onClick = () => document.setBold(range);The class earns its place specifically when you need the pair — do and undo — or when the request must outlive the call, by being queued, logged or sent.