Why 'Undo' Is One of the Hardest Features in Software
Every user expects Ctrl+Z to work. It feels like the most basic feature possible. You did something, now take it back.
I tried building undo for a side project. It broke my brain a little. The problem isn't reversing one action. It's reversing any action, in any order, without corrupting everything else.
The naive approach
Store the entire state after every change. Want to undo? Pop the stack, restore the previous snapshot.
// Snapshot-based undo
const history: string[] = []
let document = "Hello"
function doAction(fn: (doc: string) => string) {
history.push(document)
document = fn(document)
}
function undo() {
if (history.length > 0) {
document = history.pop()!
}
}
doAction((doc) => doc + " World") // document = "Hello World"
undo() // document = "Hello"This works. For a to-do app with 50 items, it's fine. For a document editor with 10,000 words or a canvas with 500 objects, you're cloning megabytes of state on every keystroke.
It also doesn't scale to redo, branching undo, or collaborative editing. But we'll get there.
The command pattern
The standard fix is to stop storing states and start storing actions.
Every change becomes a command object with two methods: execute and undo. Instead of saving snapshots, you save a list of commands.
interface Command {
execute(): void
undo(): void
}
class InsertText implements Command {
constructor(
private doc: Document,
private position: number,
private text: string
) {}
execute() {
this.doc.insertAt(this.position, this.text)
}
undo() {
this.doc.deleteAt(this.position, this.text.length)
}
}
// Usage
const cmd = new InsertText(doc, 5, "World")
cmd.execute() // inserts "World" at position 5
cmd.undo() // deletes 5 characters at position 5Undo becomes: pop the last command, call its undo() method. Redo: push it back, call execute() again.
Memory efficient. Each command stores only what changed, not the entire state. Sounds clean.
Until you try to write the undo() method for anything non-trivial.
The inversion problem
Some operations are easy to reverse. Insert text? Delete the same range. Move an object 10px right? Move it 10px left.
Some aren't.
Delete a paragraph. What was in it? You need to store the deleted content inside the command so you can restore it later:
class DeleteRange implements Command {
private deleted: string = ""
constructor(
private doc: Document,
private start: number,
private end: number
) {}
execute() {
// have to save what we're about to destroy
this.deleted = this.doc.getRange(this.start, this.end)
this.doc.deleteRange(this.start, this.end)
}
undo() {
this.doc.insertAt(this.start, this.deleted)
}
}Now your lightweight command is carrying a full copy of the data it removed. Apply a filter to a photo and it's worse: the original pixel data is gone. You either store the original pixels (back to snapshots) or make the filter mathematically reversible. Not always possible.
The command pattern works great when every operation has a clean inverse. Destructive operations don't. You end up storing "before" states inside commands anyway, scattered across hundreds of command objects instead of one snapshot stack.
Grouping
Users don't think in single operations. They think in gestures.
Type "hello". That's five InsertChar commands. But Ctrl+Z should undo the whole word, not one letter at a time. So you need to group commands.
When do you start a group? When do you end one? There's no universal answer. Text editors usually group by pauses in typing. Drawing apps group by mouse-down to mouse-up.
class CommandGroup implements Command {
constructor(private commands: Command[]) {}
execute() {
this.commands.forEach((c) => c.execute())
}
undo() {
// must reverse order: last-applied undone first
;[...this.commands].reverse().forEach((c) => c.undo())
}
}
// Group five InsertChar commands into one "type hello" gesture
const group = new CommandGroup([
new InsertChar(doc, 0, "h"),
new InsertChar(doc, 1, "e"),
new InsertChar(doc, 2, "l"),
new InsertChar(doc, 3, "l"),
new InsertChar(doc, 4, "o"),
])
group.execute() // inserts "hello"
group.undo() // removes all five charactersThe grouping logic itself is often harder to get right than the undo logic. Get it wrong and users complain that undo "skips steps" or "undoes too much."
The redo fork
Here's where most implementations quietly break.
You have a history stack: A, B, C. You undo C and B. Now you're back at state A. The redo stack holds B and C.
Now you do a new action, D.
What happens to B and C? Most apps throw them away. Your history becomes A, D. The redo stack is gone. State C is lost forever.
Some apps solve this with an undo tree instead of a stack. Vim does this. Emacs does this. Every branch point creates a fork, and you can navigate the full tree of states you've visited.
More powerful, but harder to build. And almost impossible to present in a clean UI. Linear undo loses states. Tree undo preserves everything but confuses users. Pick your trade-off.
State dependencies
This is the one that really got me.
Action B depends on action A. You undo A. What happens to B?
You create a text box (action A), then type text into it (action B). You undo A. The text box disappears. But B is still in the history, referencing a text box that no longer exists.
Now redo A. The text box comes back. Redo B. Does the text appear in the right place? What if something else moved into that position while the text box was gone?
Every command's undo() assumes a certain state of the world. When you undo out of order, those assumptions break. The options:
- Always undo in strict reverse order (most apps do this, because it's the only safe default)
- Make commands aware of the current state and adapt (complex, fragile)
- Use operational transforms to rebase commands against each other (what Google Docs does, and it's extremely hard)
Collaborative undo
Everything above assumes one user. Add a second user and it falls apart.
Alice types "hello" at position 10. Bob types "world" at position 5. Alice hits undo.
If you naively reverse Alice's insert, you delete characters at position 10. But Bob's insert shifted everything. The "hello" is now at position 15. You'd be deleting the wrong text.
Collaborative undo means each user has their own undo stack, and undoing your action can't break someone else's work. This requires transforming undo operations against every concurrent operation. Same problem as collaborative editing itself, just in reverse.
Most collaborative tools handle this poorly or don't support per-user undo at all.
What real apps do
No app solves all of these problems. They pick which trade-offs to live with:
Text editors (VS Code, Sublime) use the command pattern with grouping. Strict linear undo, no branching. This works because text operations have clean inverses, and single-user editing avoids the concurrency problem entirely.
Design tools (Figma, Photoshop) mix commands and snapshots. Reversible operations like move and resize use commands. Destructive operations like rasterization store the full previous state. The hybrid approach trades memory for correctness.
Vim uses an undo tree. Every branch is preserved, nothing is lost. The trade-off is the UI: navigating an undo tree is confusing enough that most Vim users don't even know the feature exists.
Google Docs uses operational transforms. Each user has independent undo. Operations get transformed against concurrent edits. It works, but it took Google years of engineering to get right.
Try it yourself
Do some actions, undo a few, then do something new. Watch what happens to the redo history.
The real takeaway
Undo forces you to answer hard questions about your architecture. Is your state cheap to clone? Are your operations reversible? Do your commands carry enough context to invert themselves? Can you handle concurrent reversal?
Most of the time, the answer to at least one of those is no. That's where the real engineering starts.
Ctrl+Z feels effortless. Building it is not.
If you want to go deeper: the Command pattern chapter in Game Programming Patterns is one of the best explanations of undo architecture. The Vim undo tree documentation shows what a full branching model looks like. And if you want to understand how collaborative undo works, the OT Wikipedia article is a solid starting point.
Written by Harshit Sharma. If you want to know when new posts are out, follow me on Twitter.