Command
In short: Command turns every action into an object with execute and undo, so actions are kept in a history and can be undone and redone.
Problem
An undo that remembers only one thing
The small notes editor types text and deletes from the end. Undo is done like this: the editor keeps, as text, what the last action was and what text was involved.
class Editor { constructor() { this.text = ""; this.lastAction = ""; this.lastText = ""; }
type(s) { this.text += s; this.lastAction = "type"; this.lastText = s; }
deleteLast(count) { this.lastText = this.text.slice(-count); this.text = this.text.slice(0, -count); this.lastAction = "delete"; }
undo() { if (this.lastAction === "type") { this.text = this.text.slice(0, -this.lastText.length); } else if (this.lastAction === "delete") { this.text += this.lastText; } this.lastAction = ""; }}const editor = new Editor();editor.type("Buy");editor.type(" milk");editor.undo();console.log(editor.text);editor.undo();console.log(editor.text);BuyBuyThe first undo removed " milk". The second did nothing: "Buy" stayed, because the editor remembers only one action, and after the undo even that one is gone. There is no redo at all: the text that undo removed is lost.
Every new action (bold, replace, paste) is one more branch in undo() and more fields in the editor. The knowledge of how to undo an action lives far from the action itself.
The idea
Command: the action as an object
Instead of "the last action was type", every action becomes an object that knows two things: how to run (execute()) and how to go back (undo()). Whatever it needs to go back, it keeps in its own fields.
The history keeps the commands on two stacks:
- the undo stack: the commands that ran. Undo takes the top one, calls
undo()and puts it on the redo stack; - the redo stack: the commands that were undone. Redo takes the top one, runs it again and puts it back on the undo stack;
- a new command clears the redo stack. The undone commands were made on a different text, and there is nowhere to repeat them.
This is the Command pattern. There are three roles: the command (the action as an object), the receiver (the document it works on) and the history, which calls the commands without knowing what they do.
See it
Two stacks
The document only knows how to append and how to delete from the end. removeLast returns the deleted text:
class Document { constructor() { this.text = ""; }
append(s) { this.text += s; }
removeLast(count) { const removed = this.text.slice(-count); this.text = this.text.slice(0, -count); return removed; }}A command for typing. It keeps the document and its text, so it knows how much to delete on undo:
class TypeText { constructor(doc, text) { this.doc = doc; this.text = text; }
execute() { this.doc.append(this.text); }
undo() { this.doc.removeLast(this.text.length); }}The history doesn't know what the commands are. It only calls them and moves them between the stacks:
class History { constructor() { this.undoStack = []; this.redoStack = []; }
run(command) { command.execute(); this.undoStack.push(command); this.redoStack.length = 0; }
undo() { if (this.undoStack.length === 0) { return; } const command = this.undoStack.pop(); command.undo(); this.redoStack.push(command); }
redo() { if (this.redoStack.length === 0) { return; } const command = this.redoStack.pop(); command.execute(); this.undoStack.push(command); }}const doc = new Document();const history = new History();history.run(new TypeText(doc, "Buy"));history.run(new TypeText(doc, " milk"));history.run(new TypeText(doc, " and bread"));history.undo();history.undo();history.redo();history.run(new TypeText(doc, " and eggs"));console.log(doc.text);Buy milk and eggsconst doc = new Document();const history = new History();history.run(new TypeText(doc, "Buy"));history.run(new TypeText(doc, " milk"));history.run(new TypeText(doc, " and bread"));history.undo();history.undo();history.redo();history.run(new TypeText(doc, " and eggs"));console.log(doc.text);Step by step, in words
- run calls execute() on the command: the document gets "Buy". Then the command goes onto the undo stack.
- A second command, on top of the first. The top of the stack is always the last one that ran.
- A third one. The undo stack remembers the order of everything that happened.
- undo takes the top command and calls its undo(): " and bread" is removed. The command moves to the redo stack.
- One more undo: " milk" goes too. You can go back as many steps as there are commands.
- redo takes the top of the redo stack and calls execute() again: " milk" is back. The command returns to the undo stack.
- A new command. " and bread" was undone on a different text, so redoStack.length = 0 empties the redo stack.
- The document is "Buy milk and eggs". That is what the program prints.
Undo over as many steps as you like and redo come from two stacks and three short methods. A new kind of action is a new class, and the history isn't touched.
See it
A command that remembers
Deleting can't be undone by the number of letters: you have to know what was deleted. The command remembers it in execute():
class DeleteLast { constructor(doc, count) { this.doc = doc; this.count = count; this.removed = ""; }
execute() { this.removed = this.doc.removeLast(this.count); }
undo() { this.doc.append(this.removed); }}const doc = new Document();const history = new History();history.run(new TypeText(doc, "Buy milk"));history.run(new DeleteLast(doc, 5));console.log(doc.text);history.undo();console.log(doc.text);history.redo();console.log(doc.text);BuyBuy milkBuyNot a single line of History or TypeText changed.
The check at the start of undo() isn't decoration. Without it, an undo in an empty history stops the program:
undo() { const command = this.undoStack.pop(); command.undo(); this.redoStack.push(command);}const history = new History();history.undo();TypeError: Cannot read properties of undefined (reading 'undo')pop() on an empty array doesn't throw, it returns undefined. The error shows up on the next line, when undefined has no method undo. People press Ctrl+Z more times than they typed, and that is normal, so an empty stack means "nothing to undo".
See it
A command made of two functions
History calls command.execute() and command.undo() and doesn't notice the difference:function typeText(doc, text) { return { execute: () => doc.append(text), undo: () => doc.removeLast(text.length), };}const doc = new Document();const history = new History();history.run(typeText(doc, "Buy"));history.run(typeText(doc, " milk"));history.undo();console.log(doc.text);history.redo();console.log(doc.text);BuyBuy milkThe lambdas close over (remember) doc and text, just as the fields of the class kept them. This is the usual way in JavaScript when the command is short. In Java the same is done with an anonymous class, and in C# with two Actions.
A class pays off when the command remembers something that changes between execute() and undo(), like the deleted text. Functions can do that too, but the state hides in the closure and is harder to read.
Try it
Predict the output
A note about a meeting is typed, undone and typed again. What does the program print?
const doc = new Document();const history = new History();history.run(new TypeText(doc, "Meeting"));history.run(new TypeText(doc, " at 10:00"));history.undo();history.run(new TypeText(doc, " at 11:00"));history.redo();history.undo();console.log(doc.text);Try it
Find the bug
Someone writes the history again:
class History { constructor() { this.undoStack = []; this.redoStack = []; }
run(command) { command.execute(); this.undoStack.push(command); }
undo() { if (this.undoStack.length === 0) { return; } const command = this.undoStack.pop(); command.undo(); this.redoStack.push(command); }
redo() { if (this.redoStack.length === 0) { return; } const command = this.redoStack.pop(); command.execute(); this.undoStack.push(command); }}const doc = new Document();const history = new History();history.run(new TypeText(doc, "Buy"));history.run(new TypeText(doc, " milk"));history.undo();history.run(new TypeText(doc, " bread"));history.redo();console.log(doc.text);Buy bread milkRedo put " milk" into a note where " milk" had been undone before " bread" was typed.
Pick the line that should have been followed by one more.
run puts the new command on the undo stack, but doesn't clear the redo stack. " milk" stays there, and redo repeats it on a text where it no longer fits. With more complex commands it is even worse: a delete repeated on a different text deletes the wrong letters. After this line there has to be this.redoStack.length = 0;. Then redo after a new command does nothing, and the note is "Buy bread".
The idea
When you don't need it
Command has a cost: a class (or two functions) for every action, instead of calling the method directly. It isn't worth it when:
- the action is called right away, there is no undo, it isn't queued and it isn't recorded. Then the command is a wrapper around one call;
- the state is small. If the document is a short text, the history can keep copies of the text before every action, and undo is "go back to the previous copy". That is simpler and can't get the reverse action wrong;
- undo is needed for only one step, in one place.
It is worth it when actions have to be undone over many steps, repeated, wait in a queue (jobs to send), be recorded and played again (a macro) or run later.
Exercise
Upper case
Solve the task in your editor.
Upper case. Add a command Upper that makes the whole text upper case, and whose undo brings the text back the way it was. Document and History are not touched. Type "buy milk", run Upper, then undo and redo, and print the text after each of the three steps:
BUY MILKbuy milkBUY MILKMore tasks
- A macro. A command that holds a list of commands:
execute()runs them in order, andundo()undoes them in reverse order. This is a Composite of commands. Why is the order reversed on undo? - Only 3 steps back. The history remembers at most 3 commands for undo. The oldest one is thrown away.
- The cart. Commands "add a product" and "remove a product" for a shop cart, with undo and redo. What does "remove" have to remember?
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Nested classes and data · Arrow functions · Design patterns · Composite
- Continues in: The shop: which pattern where
- Tasks on this topic: Design patterns