Command and Undo
Undo shipped as one line, and it was beautiful in the demo. Every time the document changed, we cloned the whole thing and pushed it onto an array.
let history = [];
function onChange(doc) {
history.push(structuredClone(doc)); // "undo = pop the last snapshot"
render(doc);
}
Pop the array, re-render, done. It worked on the marketing page, it worked in the sales call, it worked for three weeks.
Then a customer pasted a spreadsheet with four thousand rows into the editor and started typing. Every keystroke deep-cloned four thousand rows onto that array. The tab climbed past two gigabytes, the cursor drifted a full second behind the keyboard, and the person filing the bug thought their laptop was dying. The undo feature was eating the app.
Snapshotting the entire world after each change is the naive default, and it fails in two directions at once. It is expensive, because you copy everything even when one cell moved. And it is dumb, because the array is a stack of photographs with no captions: it knows the document is different now, but not what changed, not why, and it can never tell you. You cannot log “recolored three cells.” You cannot replay the edit on another user’s screen. You cannot group a drag into one step. You have pixels, not history.
There is a better unit to remember than the whole document. Remember the change itself.
An action you can hold in your hand
Here is the reframe the whole pattern rests on. Instead of calling a function that does the thing, you build a small object that knows how to do the thing and how to undo it, and you hand that object to something else to run.
// before: the action fires and is gone forever
grid[index] = "red";
// after: the action is a value you can keep
function paintCell(grid, index, color) {
const before = grid[index]; // capture what undo will need, now
return {
label: `paint ${index} → ${color}`,
execute() { grid[index] = color; },
undo() { grid[index] = before; },
};
}
That is a command. It is an ordinary object with two methods and enough captured state to reverse itself. paintCell does not touch the grid when you call it; it just packages the intent. Something has to actually run it, and that something is called the invoker. Keeping the invoker separate is the entire point, because the invoker is where the history lives.
Read the bottom row left to right. The editor asks paintCell for a command but does not run it. The invoker runs execute(), the grid changes, and the invoker keeps the command on a stack. That stack is the thing the naive snapshot array could never be: a list of reversible steps, each one small and self-describing.
The two-stack machine
Undo and redo are two stacks and nothing more. One holds what you have done (call it the past), the other holds what you have undone (the future). Every operation is a pop from one and a push onto the other.
function createHistory() {
const undoStack = [];
const redoStack = [];
return {
do(command) {
command.execute();
undoStack.push(command);
redoStack.length = 0; // a fresh action kills the redo branch
},
undo() {
const command = undoStack.pop();
if (!command) return;
command.undo();
redoStack.push(command);
},
redo() {
const command = redoStack.pop();
if (!command) return;
command.execute(); // execute() again, it is reusable
undoStack.push(command);
},
get canUndo() { return undoStack.length > 0; },
get canRedo() { return redoStack.length > 0; },
};
}
Twenty lines, and it is the same machine inside every editor you have used. do runs a command and files it under “done.” undo takes the top of the done pile, reverses it, and files it under “undone.” redo does the exact reverse of that. Because execute() and undo() are plain reusable methods, redo is not special code; it is just running the command forward one more time.
The one line people forget is redoStack.length = 0 inside do. Here is why it has to be there. You paint three cells, then undo twice, so two commands are sitting in the redo stack waiting to be replayed. Now you paint a new cell. The future you had undone toward no longer exists, because you just chose a different one. Keeping those stale redo entries around would let the user “redo” into a branch that was never real. So a new action truncates the future. This is redo invalidation, and every editor does it, which is why redo greys out the moment you type something after undoing.
Here it is running. Each cell you paint is a command handed to do. Undo and redo walk the two stacks, and the counters underneath show the stacks changing in real time. Paint three, undo twice, then paint a new one, and watch the redo stack snap to zero.
Notice the guard: painting a cell the color it already is produces no command at all. A no-op does not belong in the history, because undoing it would look like a bug to the user (they press undo and nothing visible happens). Deciding what counts as one undoable step is a design choice, not a mechanical one, and it is most of the real work in a good undo system.
How does a command remember how to undo itself?
A command needs enough information to reverse its own effect. There are two ways to get it, and choosing between them is the memory decision that the opening snapshot got catastrophically wrong.
Store the inverse. Capture just the values you need to put back. paintCell did this: one string, the previous color. Tiny, exact, and it scales with the size of the edit, not the size of the document.
function rename(node, name) {
const before = node.name; // the only thing undo needs
return {
execute() { node.name = name; },
undo() { node.name = before; },
};
}
Snapshot the before-state. Deep-copy the part of the world the command is about to disturb, and undo by putting the copy back. This is what structuredClone is for, and it is genuinely the right call when the edit is a tangle of changes that would be tedious and bug-prone to invert by hand.
function applyLayout(state, mutate) {
const before = structuredClone(state.doc); // copy first
return {
execute() { mutate(state.doc); }, // do anything, however messy
undo() { state.doc = structuredClone(before); },
};
}
The opening bug now has a name. That code snapshotted the whole document on every keystroke, so it paid the right-hand cost for edits that only ever needed the left-hand one. Prefer the inverse. Reach for a snapshot when inverting by hand would be error-prone, and when you do, scope it to the smallest slice of state the command actually touches, not the entire document.
In JavaScript, a command is usually a closure
If you met this pattern in a Java or C# book, it arrived wearing full armor. An interface Command with execute() and undo(). A class PaintCellCommand implements Command with a constructor that stashes the receiver and the arguments. A separate Receiver, a separate Invoker, one file per action. The class ceremony exists because those languages, for a long time, had no way to pass behavior around except by wrapping it in an object.
JavaScript never had that problem. A function is already a value, and a closure already captures the state it needs. So a command is just an object literal with two methods, and often the cleanest form is a pair of functions or even a two-element array.
// a command is two functions that share a captured value, nothing more
function makeToggle(flags, key) {
return [
() => { flags[key] = !flags[key]; }, // execute
() => { flags[key] = !flags[key]; }, // undo (a toggle is its own inverse)
];
}
The before value in paintCell lives in a closure, which is the whole receiver-plus-state apparatus collapsed into one captured variable. No interface, no base class, no this. That is not a shortcut or a hack. It is the idiomatic shape, and it is smaller and easier to test than the class version because there is less of it.
When does a class earn its place here? When commands genuinely share setup or a lot of state: dozens of command types that all take the same document and selection, want a shared label getter, maybe a coalesce method for merging consecutive edits. Then a small base class removes real duplication. Reach for the closure first. Promote to a class only when the repetition is real, not anticipated.
Macro commands: many actions, one undo
A user selects five shapes and hits “align left.” That is five position changes, but pressing undo once should put all five back, not four. The fix is a command made of commands.
function macro(label, commands) {
return {
label,
execute() {
for (const c of commands) c.execute();
},
undo() {
// reverse order: unwind like a stack, last done is first undone
for (let i = commands.length - 1; i >= 0; i--) commands[i].undo();
},
};
}
A macro satisfies the same execute/undo shape as any other command, so the invoker cannot tell it apart from a single edit. It pushes as one entry, undoes as one entry. That is the quiet strength of the pattern: a group of commands is a command, so composition costs you nothing.
The reverse-order detail is not decorative. If move B depended on where move A left things, undoing A before B would reverse against a world that no longer matches. Unwind in the opposite order you wound, exactly like nested function calls returning. Most transactional systems lean on this same rule.
Some things do not rewind
Everything so far assumed undo can restore the previous state. Now the honest part. Plenty of effects cannot be taken back. You cannot un-send an email. You cannot un-charge a credit card. You cannot un-ship a package that is on a truck. The moment a command touches the outside world, “undo” stops meaning “rewind” and starts meaning “do a new thing that makes up for it.”
That new thing is a compensating action. Undoing a charge is not deleting the charge, it is issuing a refund, and a refund is its own forward operation with its own record.
function chargeCard(gateway, customerId, cents) {
let chargeId = null;
return {
async execute() {
chargeId = await gateway.charge(customerId, cents);
},
async undo() {
// NOT a rewind. A brand-new operation that compensates.
if (chargeId) await gateway.refund(chargeId);
},
};
}
Two things follow from this, and both matter in production. First, a compensating action has to be idempotent: if the “undo” retries after a network blip, refunding twice must not double-refund. Key it on the charge id and check before you act. Second, some steps are past the point of no return. If the payment cleared but the confirmation email failed, you do not refund the payment just because an email bounced; you retry the email, or you flag it for a human. Deciding which failures compensate and which ones escalate is a business call, not a code one, and pretending an irreversible effect is reversible is how you build a very confident, very wrong undo button.
Where commands show up beyond undo
Once an action is a value, undo is only the first thing you get. Everything below falls out of the same reframe.
Queuing and deferring. A command does not have to run now. Push it onto a queue and run it later, on a schedule, or after the current batch. The invoker does not care when it fires it.
Logging and replay. A recorded stream of commands is the history of what happened. Replay it from the start to rebuild state, replay it on another machine to mirror a session, replay it into a test to reproduce a bug exactly. This is the engine under collaborative editors and event sourcing.
Remapping keyboard shortcuts. Point shortcuts at command names, not at functions, and the keymap becomes data you can edit, show in a menu, and let users rebind, all without touching the actions themselves.
const commands = {
"delete-selection": () => deleteSelection(doc),
"duplicate": () => duplicate(doc),
"align-left": () => alignLeft(doc),
};
// the keymap is just data pointing at command names
const keymap = {
"Backspace": "delete-selection",
"Meta+d": "duplicate",
"Meta+[": "align-left",
};
function onKey(combo) {
const name = keymap[combo];
if (name) commands[name](); // one lookup drives menus, palette, and keys
}
That single indirection is why a command palette (the “search all actions” box in modern editors) is nearly free once you have commands: it is just the same registry, listed and filtered.
When a command is over-engineering
The pattern has a real cost, and the failure mode is turning every function call into a ceremony. If you do not need undo, do not need a queue, do not need replay, and do not need a history, then wrapping saveSettings() in a command object with an execute method is pure overhead. You added a layer, a lookup, and a vocabulary, and bought nothing.
A few honest lines to draw:
- No undo, no queue, no log? Just call the function. The command pattern earns its keep only when you keep the action around for later. If it fires and you forget it, an object is worse than a call.
- Not every button is a command. A “command bus” that every trivial click routes through, because it felt architectural, is the enterprise version of the premature strategy map. It adds indirection to code that had one caller and one callee.
- A command that cannot undo is often just a function with a fancy name. If you are only ever going forward, the
undomethod is dead weight and the honest thing is a plain handler. Introduce the command object at the point where you actually need to remember or reverse the action, not before.
The tell that you do want commands is when you catch yourself writing a second undo() that special-cases each kind of edit, or snapshotting the whole document to fake a history, or threading “what did the user just do” through five function signatures. Those are the symptoms the command pattern cures. Absent them, it is a cure looking for a disease.
Summary
- A command turns an action into a value: a small object (usually a closure, sometimes a class) with
execute()andundo(), plus just enough captured state to reverse itself. You run it through an invoker instead of calling it directly, and the invoker keeps the history. - Undo and redo are two stacks.
doruns a command and pushes it onto the undo stack;undopops from undo and pushes onto redo;redodoes the reverse. The whole machine is about twenty lines. - A new action after undoing clears the redo stack. That is redo invalidation, and it is why redo greys out the moment you type. A full undo tree is possible but rarely worth the UI it demands.
- A command remembers how to undo by storing the inverse (small, scales with the edit) or snapshotting the before-state with
structuredClone(simple, scales with the document). Prefer the inverse; snapshot only when inverting by hand is bug-prone, and scope the snapshot tight. structuredClonecopies data, not behavior: it throws on functions and DOM nodes and drops class prototypes, soinstanceoffails on the clone. Snapshots suit plain data, not graphs of class instances.- In JavaScript a command is usually a closure, not the Java interface-plus-classes stack. Reach for a class only when many command types share real setup or state.
- A macro groups commands into one: run children in order, undo them in reverse, present as a single history entry. It satisfies the same shape, so the invoker cannot tell it apart.
- Some effects do not rewind. Undo becomes a compensating action (refund, not un-charge), which must be idempotent and recorded as its own event. Editor undo and distributed compensation are the same pattern at different scales.
- The same “action as a value” gives you queuing, background jobs, logging and replay, CQRS commands, and keybindings that point at command names. Note that a serialisable command is data plus a handler registry, not a closure.
- Use it when you need undo/redo, a queue, replay, or a command palette. Avoid it when the action fires and is forgotten: no history means the command object is ceremony, and a plain function call is the honest choice.