← Writing
The Editorial · Engineering

The Command pattern — or why Ctrl+Z works in Google Docs and not in your admin panel

Issue 024Aug 6, 20269 min read

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 same order, shouted vs written downwaiter"one dosa, no onion!"kitchennothing is left over — you can't queue it, cancel it or count itwaiterorder sliptable 61 masala dosano onionkitchensame dosa — plus something you can pin, queue, cancel and tally
Fig 1The order gets cooked either way. Only one version leaves you holding something afterwards.AI-generated figure

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:

tasks.jsJavaScript
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.

buttons wired straight into the datadelete taskrename listclear allyour datatask 41 (gone)task 42task 43history: nothing to undothe change happened; nothing remains that describes it
Fig 2Every button reaches straight into the data. The action happens and nothing survives that describes it, so the history box is empty by construction.AI-generated figure

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.

commands.jsJavaScript
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:

history.jsJavaScript
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 again
two stacks: what has been done, what has been taken backundopastadd taskrename listdelete 41your listthe actual statefuturedelete 41redodo something new and the right stack is thrown away — that is why redo greys out
Fig 3Undo moves the top slip from the left stack to the right one and applies its opposite. Redo moves it back. That is the whole mechanism.AI-generated figure

That 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.

the tap already worked — the slip just has not left the phoneno signalmark donerenamedelete 413 slips waiting, in orderback onlinemark donerenamedelete 41serverreplayed 1 → 2 → 3a lost function call is lost; a stored slip is just a slip that has not run yetthe same queue is what makes a failed job re-runnable
Fig 4A lost function call is lost. A stored command is just a command that hasn't run yet — which is what "it works offline" actually means underneath.AI-generated figure

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.

All issues