Command Pattern and Undo/Redo
The Operation You Need to Remember
You're building a drawing app. The user draws a circle, changes its color, moves it, then hits Ctrl+Z three times. Everything should reverse in order — the circle moves back, its color reverts, and the circle disappears. How do you implement this?
The naive approach tracks every state snapshot. Draw a circle? Save the entire canvas state. Move it? Save the entire canvas again. With a complex document, you're storing megabytes of state per operation. The Command pattern takes a fundamentally different approach: instead of saving state, it saves the operations themselves — and each operation knows how to undo itself.
// Instead of storing snapshots:
// [fullState1, fullState2, fullState3, ...] ← expensive
// Store operations:
// [createCircle, setColor, move] ← lightweight + reversible
Think of the Command pattern like a recipe card. Instead of storing a photo of the finished dish at every step (expensive), you write down each step on a card: "add 2 eggs", "stir for 3 minutes", "bake at 350F". To undo, you read the cards in reverse: "remove from oven", "un-stir" (ok, some operations are harder to reverse than others). The point is — you store the instructions, not the results.
The Core Structure
A command is an object with at least two methods: execute and undo. Some patterns also include redo, but in most implementations, redo is just calling execute again.
interface Command {
execute(): void;
undo(): void;
description: string;
}
class CommandHistory {
private undoStack: Command[] = [];
private redoStack: Command[] = [];
execute(command: Command): void {
command.execute();
this.undoStack.push(command);
this.redoStack = [];
}
undo(): void {
const command = this.undoStack.pop();
if (!command) return;
command.undo();
this.redoStack.push(command);
}
redo(): void {
const command = this.redoStack.pop();
if (!command) return;
command.execute();
this.undoStack.push(command);
}
get canUndo(): boolean {
return this.undoStack.length > 0;
}
get canRedo(): boolean {
return this.redoStack.length > 0;
}
}
Notice that executing a new command clears the redo stack. This is standard behavior — if you undo three times and then do something new, the undone operations are gone. This matches how Ctrl+Z works in every editor.
Building a Text Editor with Undo/Redo
Let's build something real — a text editor where every operation is a command:
class TextDocument {
content = "";
insertAt(position: number, text: string): void {
this.content =
this.content.slice(0, position) + text + this.content.slice(position);
}
deleteRange(start: number, length: number): string {
const deleted = this.content.slice(start, start + length);
this.content =
this.content.slice(0, start) + this.content.slice(start + length);
return deleted;
}
}
class InsertCommand implements Command {
description: string;
constructor(
private doc: TextDocument,
private position: number,
private text: string
) {
this.description = `Insert "${text}" at ${position}`;
}
execute(): void {
this.doc.insertAt(this.position, this.text);
}
undo(): void {
this.doc.deleteRange(this.position, this.text.length);
}
}
class DeleteCommand implements Command {
description: string;
private deletedText = "";
constructor(
private doc: TextDocument,
private position: number,
private length: number
) {
this.description = `Delete ${length} chars at ${position}`;
}
execute(): void {
this.deletedText = this.doc.deleteRange(this.position, this.length);
}
undo(): void {
this.doc.insertAt(this.position, this.deletedText);
}
}
Now operations are composable and reversible:
const doc = new TextDocument();
const history = new CommandHistory();
history.execute(new InsertCommand(doc, 0, "Hello"));
// doc.content: "Hello"
history.execute(new InsertCommand(doc, 5, " World"));
// doc.content: "Hello World"
history.execute(new DeleteCommand(doc, 5, 6));
// doc.content: "Hello"
history.undo();
// doc.content: "Hello World"
history.undo();
// doc.content: "Hello"
history.redo();
// doc.content: "Hello World"
Production Scenario: Canvas Drawing App
Here's how design tools like Figma structure their command system:
interface Shape {
id: string;
type: "circle" | "rect";
x: number;
y: number;
width: number;
height: number;
fill: string;
}
class Canvas {
shapes = new Map<string, Shape>();
addShape(shape: Shape): void {
this.shapes.set(shape.id, shape);
}
removeShape(id: string): Shape | undefined {
const shape = this.shapes.get(id);
this.shapes.delete(id);
return shape;
}
updateShape(id: string, props: Partial<Shape>): Shape | undefined {
const shape = this.shapes.get(id);
if (!shape) return undefined;
const previous = { ...shape };
Object.assign(shape, props);
return previous;
}
}
class AddShapeCommand implements Command {
description: string;
constructor(private canvas: Canvas, private shape: Shape) {
this.description = `Add ${shape.type} (${shape.id})`;
}
execute(): void {
this.canvas.addShape({ ...this.shape });
}
undo(): void {
this.canvas.removeShape(this.shape.id);
}
}
class MoveShapeCommand implements Command {
description: string;
private previousX = 0;
private previousY = 0;
constructor(
private canvas: Canvas,
private shapeId: string,
private newX: number,
private newY: number
) {
this.description = `Move ${shapeId} to (${newX}, ${newY})`;
}
execute(): void {
const prev = this.canvas.updateShape(this.shapeId, {
x: this.newX,
y: this.newY,
});
if (prev) {
this.previousX = prev.x;
this.previousY = prev.y;
}
}
undo(): void {
this.canvas.updateShape(this.shapeId, {
x: this.previousX,
y: this.previousY,
});
}
}
class ChangeColorCommand implements Command {
description: string;
private previousFill = "";
constructor(
private canvas: Canvas,
private shapeId: string,
private newFill: string
) {
this.description = `Change ${shapeId} color to ${newFill}`;
}
execute(): void {
const prev = this.canvas.updateShape(this.shapeId, { fill: this.newFill });
if (prev) this.previousFill = prev.fill;
}
undo(): void {
this.canvas.updateShape(this.shapeId, { fill: this.previousFill });
}
}
Composite Commands (Macros)
Sometimes a user action involves multiple operations that should undo as a single unit. A "paste formatted text" might insert text AND apply styling. A CompositeCommand groups them:
class CompositeCommand implements Command {
description: string;
constructor(private commands: Command[], description?: string) {
this.description = description ?? commands.map(c => c.description).join(" + ");
}
execute(): void {
this.commands.forEach(cmd => cmd.execute());
}
undo(): void {
[...this.commands].reverse().forEach(cmd => cmd.undo());
}
}
const duplicateAndMove = new CompositeCommand([
new AddShapeCommand(canvas, { ...originalShape, id: "shape_copy" }),
new MoveShapeCommand(canvas, "shape_copy", original.x + 20, original.y + 20),
], "Duplicate shape");
history.execute(duplicateAndMove);
history.undo(); // Both the move and the add are reversed
Notice that undo reverses the commands in the opposite order. If you add a shape and then move it, undoing must un-move first, then remove — otherwise you'd try to un-move a shape that's already been removed.
Command Pattern with React
In React, the Command pattern works naturally with useReducer for state management:
interface EditorState {
content: string;
undoStack: Command[];
redoStack: Command[];
}
type EditorAction =
| { type: "EXECUTE"; command: Command }
| { type: "UNDO" }
| { type: "REDO" };
function editorReducer(state: EditorState, action: EditorAction): EditorState {
switch (action.type) {
case "EXECUTE": {
action.command.execute();
return {
...state,
content: getDocContent(),
undoStack: [...state.undoStack, action.command],
redoStack: [],
};
}
case "UNDO": {
const command = state.undoStack[state.undoStack.length - 1];
if (!command) return state;
command.undo();
return {
...state,
content: getDocContent(),
undoStack: state.undoStack.slice(0, -1),
redoStack: [...state.redoStack, command],
};
}
case "REDO": {
const command = state.redoStack[state.redoStack.length - 1];
if (!command) return state;
command.execute();
return {
...state,
content: getDocContent(),
undoStack: [...state.undoStack, command],
redoStack: state.redoStack.slice(0, -1),
};
}
}
}
| What developers do | What they should do |
|---|---|
| Storing the entire application state as a snapshot for each undo step State snapshots are O(state_size) per operation. Command objects are O(1) per operation — they only store the delta. For large documents or canvases, snapshots quickly consume hundreds of megabytes | Store lightweight command objects that know how to execute and undo themselves |
| Making commands that modify the receiver directly without capturing previous state If a command captures state in its constructor, the document may change before execute runs. Capture the previous state inside execute() to guarantee correctness regardless of when the command runs | Always capture the state needed for undo DURING execute, not in the constructor |
| Forgetting to clear the redo stack when a new command is executed If you keep the redo stack after a new action, redoing would replay operations from a state that no longer exists, leading to corrupted data. Every major editor clears redo on new input | Always clear redo on new execute — the old future is invalid |
Challenge
Build an undo/redo system for a todo list with add, remove, toggle, and rename operations.
Try to solve it before peeking at the answer.
// Requirements:
// 1. TodoList with items: { id, text, done }
// 2. Commands: AddTodo, RemoveTodo, ToggleTodo, RenameTodo
// 3. Each command supports execute() and undo()
// 4. CommandHistory with execute(), undo(), redo()
const todos = new TodoList();
const history = new CommandHistory();
history.execute(new AddTodo(todos, "Buy milk"));
history.execute(new AddTodo(todos, "Walk dog"));
history.execute(new ToggleTodo(todos, "todo_1"));
// [{ id: "todo_1", text: "Buy milk", done: true },
// { id: "todo_2", text: "Walk dog", done: false }]
history.undo(); // Un-toggle: done → false
history.undo(); // Remove "Walk dog"
// [{ id: "todo_1", text: "Buy milk", done: false }]- 1Commands encapsulate operations as objects — each command stores everything needed to execute and undo itself
- 2Always capture previous state during execute(), not in the constructor, because the document state may change between creation and execution
- 3Clear the redo stack on new command execution — the old future is invalid after a new action
- 4Use CompositeCommand for multi-step operations that should undo as a single unit, always reversing sub-commands in reverse order
- 5Commands are lightweight — storing operation deltas is O(1) per operation vs. O(state_size) for snapshots