The Command pattern — or why Ctrl+Z works in Google Docs and not in your admin panel
Nobody in a busy restaurant shouts the order at the kitchen. They write it on a slip — same dosa, but now the order is a thing you can pin to a rail, queue behind four others, cancel when the table walks out, and count at closing. The Command pattern is that habit applied to code: write the request down before you do it. That one move is where undo, activity logs, offline mode and retry all come from — and why, if nobody made it early, "can we add undo?" is a rewrite and not a ticket.
The issue
You have pressed Ctrl+Z maybe ten thousand times. It works in Google Docs, in Figma, in Photoshop, in the Notes app, on your phone's keyboard. Then you open the admin panel your own team built, delete the wrong row, and there is simply nothing there. No undo, no way back, just a support ticket and a slightly worse afternoon. The engineers weren't lazy. What happened is that a decision was made months earlier, long before that delete button existed, about whether "delete this row" would ever be written down as a thing or merely done.
That is the entire Command pattern, and it's worth stating in plain language before any code shows up. A normal function call is a verb. It happens, it changes something, and it evaporates — the only trace it leaves is the damage. A command is that same request turned into a noun: an object that says what should happen, sitting in your hand, before and after it runs.
Watch a busy restaurant and you'll see people who worked this out a century before software did. Nobody shouts the order at the kitchen. The waiter writes it on a slip: table 6, one masala dosa, no onion. Same dosa either way. But the slip can be pinned to a rail, queued behind four other slips, handed to whichever cook is free, cancelled when the table gets up and leaves, re-read when the cook forgets, and counted at closing time. Shout the order and you get exactly one thing back: the dosa. Write it down and you get the dosa and everything else.
The version without slips
Here's the code everybody writes first, and it is genuinely the right code to write first. A button, a handler, the data:
function onDelete(id) {
tasks = tasks.filter((t) => t.id !== id);
render();
}Three lines. Works. Ships on a Tuesday. Then, over the next six months, four tickets arrive — and to everyone involved they look like four unrelated features.
One decision, taken before the first button was built, is the difference between four features and four rewrites.
Fix: write it down, then do it
A command is an object with two methods: do the thing and put it back. Nothing more clever than that.
class RemoveTask {
constructor(id) {
this.id = id;
}
execute(list) {
this.index = list.tasks.findIndex((t) => t.id === this.id);
this.removed = list.tasks[this.index]; // keep it BEFORE destroying it
list.tasks.splice(this.index, 1);
}
undo(list) {
list.tasks.splice(this.index, 0, this.removed);
}
}The line with the comment on it is the one everyone gets wrong the first time, and it's the real lesson of this issue. Undo is not "run the code backwards". Deleting is easy; un-deleting needs the deleted thing, and the only moment anybody had that thing in their hands was the instant before it went. So the command saves it on the way through. Same story for "move this to the top" — undo means putting it back at position 7, which you only know if you wrote 7 down first. Undo is mostly bookkeeping done before the damage, not cleverness after it.
Two stacks and nothing else
The thing that runs the commands — the textbooks call it the invoker, the rest of us call it the history — is smaller than the command itself. Two arrays:
class History {
constructor(list) {
this.list = list;
this.past = [];
this.future = [];
}
run(command) {
command.execute(this.list);
this.past.push(command);
this.future.length = 0; // history just forked — the redos are dead
}
undo() {
const command = this.past.pop();
if (!command) return;
command.undo(this.list);
this.future.push(command);
}
redo() {
const command = this.future.pop();
if (!command) return;
command.execute(this.list);
this.past.push(command);
}
}
const history = new History(list);
history.run(new RemoveTask(41)); // gone
history.undo(); // back
history.redo(); // gone againThat one commented line explains a behaviour you have known for years without ever asking about it: undo three times, then type a single character, and the redo button goes grey. That's future.length = 0. It isn't a bug or a limitation — once you've undone your way back and then done something different, history has forked, and there is no sane answer to what "redo" should mean any more. So the branch gets thrown away.
It also explains why typing forty letters isn't forty undos. Editors coalesce — successive commands of the same kind get merged into one while you're still typing, so Ctrl+Z takes back a word or a sentence rather than one keystroke. Nice detail to know exists, because the first hand-rolled undo anybody builds always feels maddening for exactly this reason.
The part that isn't about undo at all
Undo is the headline, but in most products it isn't the reason the pattern earns its place. Once a request is an object, three other features stop being projects.
The activity log is free — every command already is the log line, so "Priya deleted task 41 at 2:14am" is one function away from something you already have. Offline is a queue: you're in a lift, or on the metro between two stations, and you mark three things done. The taps worked, as far as you're concerned. What actually happened is that three slips went into a queue on your phone, and when the signal returns they get sent in the order they were written. And retry is the same queue seen from the server's side — a failed job you still have a slip for is a job you can simply run again.
My favourite example of all of these is the cheapest one ever shipped. Gmail's Undo Send does not un-send your email. It can't — once a mail leaves the building it's somebody else's property. What Gmail does instead is not send it for ten seconds. The command sits on the rail with a timer, and "undo" quietly takes it off before the kitchen ever sees it. Sometimes the right undo isn't reversal, it's delay — and noticing that is a product decision, not an engineering one.
If you've used Redux you have already been writing commands without calling them that: { type: "task/removed", payload: 41 } is a slip, dispatch is the rail, and the reducer is the cook. It's also exactly why Redux DevTools can travel back in time and replay your session — the app kept the slips. The same shape turns up in state machines like XState, in every job queue you've ever configured, and in the event-sourced systems where the stored commands are the database and the current state is just what you get by replaying them.
If you don't write the code
This is the pattern most worth understanding from the other side of the table, because its cost is entirely in the timing. "Can we add undo?" asked in week two is a small ticket. Asked in month nine, it means going back through every action in the product and retrofitting a record that was never kept — and the honest estimate makes you sound obstructive when you are only being accurate.
Two questions settle it early, and neither needs an engineer to answer. Can a user destroy something in a single tap? And do they do it repeatedly, quickly, on a phone? Two yeses and you need undo, and the moment to say so is before the delete button is built, not after somebody's intern clears two hundred rows.
There's a design argument sitting on top of the technical one, too. A confirmation modal asks the user to be certain in advance — and by the third time they've seen it, they click through it without reading, which means it now protects nobody and annoys everybody. Undo lets them be wrong and recover, which is how people actually behave. The better experience is almost always undo; it's just that choosing it is an architecture decision taken months before anyone thinks of it as a UX one.
When not to reach for one
Written down like this the pattern sounds like it should be everywhere, and that instinct is how codebases end up with forty classes doing what a switch statement did.
The test is the same one as always: are you keeping the slip because something downstream genuinely needs to read it — an undo stack, a queue, a log, a retry — or because writing it down felt more professional? If it's the second, shout the order. The dosa arrives either way.
For the more formal treatment, plus where this leads once you follow it far enough into CQRS and event sourcing, patterns.dev's Command Pattern writeup is the right next stop. And if you want the neighbouring idea — one shared object handed out instead of thousands of copies — that's Issue 023.