Skip to content

Command Pattern and Undo/Redo

advanced18 min read

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
Mental Model

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.

Quiz
Why does executing a new command clear the redo stack?

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"
Quiz
Why does the DeleteCommand store the deleted text in execute() instead of the constructor?

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.

Quiz
Why does CompositeCommand undo its sub-commands in reverse order?

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

Challenge:

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 }]
Key Rules
  1. 1Commands encapsulate operations as objects — each command stores everything needed to execute and undo itself
  2. 2Always capture previous state during execute(), not in the constructor, because the document state may change between creation and execution
  3. 3Clear the redo stack on new command execution — the old future is invalid after a new action
  4. 4Use CompositeCommand for multi-step operations that should undo as a single unit, always reversing sub-commands in reverse order
  5. 5Commands are lightweight — storing operation deltas is O(1) per operation vs. O(state_size) for snapshots