Siddhant Deval
Siddhant Deval
frontend5 min read

The Command & Memento Patterns: Building Enterprise-Grade Undo/Redo History

Storing full state snapshots in React useState arrays for undo/redo causes memory leaks and performance freezing with large documents. The Command and Memento patterns provide deterministic, memory-bounded, reversible state transactions — the same technique used by Figma, Photoshop, and VS Code.

The Command & Memento Patterns: Building Enterprise-Grade Undo/Redo History

Domain objects are autonomous state machines with enforced invariants — they are not passive bags of data passed between controller functions. Every mutation to the canvas studio's shape tree — moving, resizing, adding, deleting, grouping — must be reversible. Naive undo/redo with history: Shape[][] snapshots (Part 2's Hook Soup example) stores the entire shape array per operation. With 1,000 shapes and 100 history entries, that is 100,000 shape objects in memory. It also makes grouped operations impossible: "move five shapes" must be one undo step, not five.

The Command Pattern encapsulates each mutation as an object with execute() and undo() methods. The Memento Pattern captures the pre-mutation state needed for reversal. Together they produce an undo/redo history that: stores deltas not snapshots, supports grouped multi-step operations, can be serialized to JSON for session persistence, and integrates cleanly into the domain model without touching the React layer.


1. The Snapshot Undo Anti-Pattern

TYPESCRIPT
// ❌ Snapshot undo — stores entire shape array per operation
const [shapes, setShapes] = useState<ShapeData[]>([]);
const [history, setHistory] = useState<ShapeData[][]>([[]]); // Stack of snapshots
const [historyIndex, setHistoryIndex] = useState(0);

const moveShape = useCallback((id: string, dx: number, dy: number) => {
  const next = shapes.map(s => s.id === id ? { ...s, x: s.x + dx, y: s.y + dy } : s);
  const newHistory = history.slice(0, historyIndex + 1);
  newHistory.push(next); // ← Stores ALL shapes, not just the delta
  setHistory(newHistory);
  setHistoryIndex(newHistory.length - 1);
  setShapes(next);
}, [shapes, history, historyIndex]);

Three fundamental problems:

  1. Memory: 1,000 shapes × 100 history entries × ~200 bytes per shape = ~20MB for undo history alone
  2. Grouping: moveShape called five times produces five undo steps — no way to batch them into one
  3. Determinism: snapshot must be cloned perfectly or aliasing bugs cause phantom mutations on undo

2. The ICommand Interface

TYPESCRIPT
// src/domain/history/ICommand.ts
export interface ICommand {
  readonly id: CommandId;
  readonly name: string;       // Display name for history panel: "Move 3 shapes"
  readonly timestamp: number;  // For session serialization

  /** Apply the mutation to the document */
  execute(doc: CanvasDocument): void;

  /** Reverse the mutation exactly */
  undo(doc: CanvasDocument): void;

  /**
   * Optional: merge with a previous command of the same type.
   * Used for continuous operations like dragging — collapse 60 move commands into one.
   * Returns true if the merge was accepted (caller should discard this command).
   */
  mergeWith?(previous: ICommand): boolean;
}

The CanvasDocument is passed into execute() and undo() — commands are stateless with respect to the document. They capture only the delta needed for reversal.


3. Concrete Commands

3.1 MoveCommand — Delta Storage

TYPESCRIPT
// src/domain/history/commands/MoveCommand.ts
export class MoveCommand implements ICommand {
  readonly id: CommandId;
  readonly name: string;
  readonly timestamp: number;

  readonly #shapeId: ShapeId;
  readonly #dx: number;
  readonly #dy: number;

  constructor(shapeId: ShapeId, dx: number, dy: number) {
    this.id        = makeCommandId();
    this.name      = `Move shape`;
    this.timestamp = Date.now();
    this.#shapeId  = shapeId;
    this.#dx       = dx;
    this.#dy       = dy;
  }

  execute(doc: CanvasDocument): void {
    doc.getShape(this.#shapeId)?.move(this.#dx, this.#dy);
  }

  undo(doc: CanvasDocument): void {
    doc.getShape(this.#shapeId)?.move(-this.#dx, -this.#dy); // Exact reversal
  }

  // Merge continuous drags into a single history entry
  mergeWith(previous: ICommand): boolean {
    if (!(previous instanceof MoveCommand)) return false;
    if ((previous as any).#shapeId !== this.#shapeId) return false;
    // Accumulate the delta onto the previous command
    (previous as any).#dx += this.#dx;
    (previous as any).#dy += this.#dy;
    return true; // This command is merged — caller discards it
  }
}

MoveCommand stores dx and dy — two numbers. Not 1,000 shape objects. Undo is -dx, -dy. Moving 1,000 shapes 100 times costs 100 × 2 numbers, not 100,000 shape clones.

3.2 ResizeCommand — Memento for Complex State

Resizing cannot be reversed with a simple delta — the aspect ratio, constraints, and origin point make -dw / -dh insufficient. The Memento Pattern captures a minimal snapshot of the shape's pre-resize state:

TYPESCRIPT
// src/domain/history/commands/ResizeCommand.ts

// Memento: minimal state snapshot needed for reversal — NOT the full shape
interface RectShapeMemento {
  x: number; y: number;
  width: number; height: number;
}

export class ResizeCommand implements ICommand {
  readonly id: CommandId;
  readonly name: string;
  readonly timestamp: number;

  readonly #shapeId: ShapeId;
  readonly #before: RectShapeMemento; // Captured PRE-mutation
  readonly #after: RectShapeMemento;  // Captured POST-mutation

  constructor(shapeId: ShapeId, before: RectShapeMemento, after: RectShapeMemento) {
    this.id        = makeCommandId();
    this.name      = 'Resize shape';
    this.timestamp = Date.now();
    this.#shapeId  = shapeId;
    this.#before   = { ...before }; // Defensive clone — plain object, no references
    this.#after    = { ...after };
  }

  execute(doc: CanvasDocument): void {
    const shape = doc.getShape(this.#shapeId) as RectShape;
    if (!shape) return;
    shape.moveTo(this.#after.x, this.#after.y);
    shape.resize(this.#after.width, this.#after.height);
  }

  undo(doc: CanvasDocument): void {
    const shape = doc.getShape(this.#shapeId) as RectShape;
    if (!shape) return;
    shape.moveTo(this.#before.x, this.#before.y); // Restore exact pre-resize position
    shape.resize(this.#before.width, this.#before.height);
  }
}

The Memento captures { x, y, width, height } — eight numbers. Not the entire shape. Undo restores the pre-resize state exactly, regardless of how complex the resize calculation was.

3.3 AddShapeCommand and DeleteShapeCommand

TYPESCRIPT
// src/domain/history/commands/AddShapeCommand.ts
export class AddShapeCommand implements ICommand {
  readonly id: CommandId;
  readonly name: string;
  readonly timestamp: number;
  readonly #shape: IShape;

  constructor(shape: IShape) {
    this.id       = makeCommandId();
    this.name     = `Add ${shape.toData().type}`;
    this.timestamp = Date.now();
    this.#shape   = shape;
  }

  execute(doc: CanvasDocument): void { doc.addShape(this.#shape); }
  undo(doc: CanvasDocument): void    { doc.removeShape(this.#shape.id); }
}
TYPESCRIPT
// src/domain/history/commands/DeleteShapeCommand.ts
export class DeleteShapeCommand implements ICommand {
  readonly id: CommandId;
  readonly name: string;
  readonly timestamp: number;

  readonly #shapeId: ShapeId;
  #deletedShape: IShape | null = null; // Captured at execute time for undo restoration

  constructor(shapeId: ShapeId) {
    this.id        = makeCommandId();
    this.name      = 'Delete shape';
    this.timestamp = Date.now();
    this.#shapeId  = shapeId;
  }

  execute(doc: CanvasDocument): void {
    // Capture the shape BEFORE deleting — needed for undo restoration
    this.#deletedShape = doc.getShape(this.#shapeId);
    if (!this.#deletedShape) throw new DomainError(`Shape ${this.#shapeId} not found`);
    doc.removeShape(this.#shapeId);
  }

  undo(doc: CanvasDocument): void {
    if (!this.#deletedShape) throw new DomainError('Cannot undo delete: shape not captured');
    doc.addShape(this.#deletedShape); // Restore the exact same shape object
  }
}

3.4 CompositeCommand: Grouping Operations Into One Undo Step

Multiple commands grouped as a single undo entry:

TYPESCRIPT
// src/domain/history/commands/CompositeCommand.ts
export class CompositeCommand implements ICommand {
  readonly id: CommandId;
  readonly name: string;
  readonly timestamp: number;
  readonly #commands: ICommand[];

  constructor(name: string, commands: ICommand[]) {
    if (commands.length === 0) throw new DomainError('CompositeCommand requires at least one command');
    this.id        = makeCommandId();
    this.name      = name;
    this.timestamp = Date.now();
    this.#commands = commands;
  }

  execute(doc: CanvasDocument): void {
    for (const cmd of this.#commands) cmd.execute(doc);
  }

  undo(doc: CanvasDocument): void {
    // Undo in reverse order — last command undone first
    for (let i = this.#commands.length - 1; i >= 0; i--) {
      this.#commands[i].undo(doc);
    }
  }
}

Moving five selected shapes becomes:

TYPESCRIPT
const commands = selectedIds.map(id => new MoveCommand(id, dx, dy));
const grouped = new CompositeCommand(`Move ${selectedIds.size} shapes`, commands);
history.push(grouped); // One undo step — undoes all five moves simultaneously

4. CommandHistory: The Undo/Redo Stack

TYPESCRIPT
// src/domain/history/CommandHistory.ts
export class CommandHistory {
  #past: ICommand[] = [];
  #future: ICommand[] = [];
  readonly #maxSize: number;
  #mergeWindowMs: number;

  constructor(maxSize = 100, mergeWindowMs = 300) {
    this.#maxSize = maxSize;
    this.#mergeWindowMs = mergeWindowMs;
  }

  get canUndo(): boolean { return this.#past.length > 0; }
  get canRedo(): boolean { return this.#future.length > 0; }
  get entries(): readonly ICommand[] { return this.#past; }

  push(command: ICommand, doc: CanvasDocument): void {
    // Execute the command
    command.execute(doc);

    // Attempt to merge with the most recent command (for continuous drags)
    const last = this.#past[this.#past.length - 1];
    if (last && command.mergeWith && command.mergeWith(last)) {
      // Merge accepted — command is absorbed into 'last', do not push separately
      this.#future = []; // New action clears redo stack
      return;
    }

    // New action clears redo stack
    this.#future = [];

    // Push to past
    this.#past.push(command);

    // Enforce max size — drop oldest entry
    if (this.#past.length > this.#maxSize) {
      this.#past.shift();
    }
  }

  undo(doc: CanvasDocument): ICommand | null {
    const command = this.#past.pop();
    if (!command) return null;
    command.undo(doc);
    this.#future.unshift(command); // Push to front of redo stack
    return command;
  }

  redo(doc: CanvasDocument): ICommand | null {
    const command = this.#future.shift();
    if (!command) return null;
    command.execute(doc);
    this.#past.push(command);
    return command;
  }

  clear(): void {
    this.#past = [];
    this.#future = [];
  }

  // Serializable snapshot for session persistence
  toJSON(): object[] {
    return this.#past.map(cmd => ({
      id:        cmd.id,
      name:      cmd.name,
      timestamp: cmd.timestamp,
    }));
  }
}

5. Integrating History Into CanvasDocument

TYPESCRIPT
// src/domain/canvas/CanvasDocument.ts (updated)
export class CanvasDocument extends EventEmitter<CanvasDocumentEvents> {
  #shapes: Map<ShapeId, IShape> = new Map();
  #selection: SelectionFSM     = new SelectionFSM();
  #history: CommandHistory     = new CommandHistory(100);

  /** Execute a command through the history system — supports undo */
  execute(command: ICommand): void {
    this.#history.push(command, this);
    this.emit('shapes:changed');
    this.emit('history:changed', { canUndo: this.#history.canUndo, canRedo: this.#history.canRedo });
  }

  undo(): void {
    const cmd = this.#history.undo(this);
    if (cmd) {
      this.emit('shapes:changed');
      this.emit('history:changed', { canUndo: this.#history.canUndo, canRedo: this.#history.canRedo });
    }
  }

  redo(): void {
    const cmd = this.#history.redo(this);
    if (cmd) {
      this.emit('shapes:changed');
      this.emit('history:changed', { canUndo: this.#history.canUndo, canRedo: this.#history.canRedo });
    }
  }

  get canUndo(): boolean { return this.#history.canUndo; }
  get canRedo(): boolean { return this.#history.canRedo; }
}

Usage at the tool level:

TYPESCRIPT
// In RectTool.onPointerUp():
onPointerUp(event: ToolPointerEvent, doc: CanvasDocument): void {
  if (!this.#drawStart) return;
  const r = rectFromPoints(this.#drawStart, event.point);
  if (r.width > 2 && r.height > 2) {
    const shape = new RectShape(makeShapeId(), r.x, r.y, r.width, r.height);
    doc.execute(new AddShapeCommand(shape)); // ← Goes through history
  }
  doc.clearPreview();
  this.#drawStart = null;
}

The tool does not call doc.addShape() directly — it calls doc.execute(command). Every mutation that should be undoable goes through the command system.


6. Testing Commands

TYPESCRIPT
// tests/unit/domain/history/commands.test.ts
describe('MoveCommand', () => {
  let doc: CanvasDocument;
  let shape: RectShape;

  beforeEach(() => {
    doc = new CanvasDocument();
    shape = new RectShape(makeShapeId(), 10, 20, 100, 50);
    doc.addShapeDirect(shape); // Internal method — bypasses history for test setup
  });

  it('should move shape on execute and restore on undo', () => {
    const cmd = new MoveCommand(shape.id, 50, 30);
    cmd.execute(doc);
    expect(shape.x).toBe(60); expect(shape.y).toBe(50);

    cmd.undo(doc);
    expect(shape.x).toBe(10); expect(shape.y).toBe(20);
  });

  it('should merge consecutive moves into one history entry', () => {
    const cmd1 = new MoveCommand(shape.id, 10, 0);
    const cmd2 = new MoveCommand(shape.id, 15, 0);

    cmd1.execute(doc);
    const merged = cmd2.mergeWith!(cmd1);

    expect(merged).toBe(true);
    cmd1.undo(doc); // Undo the merged command
    expect(shape.x).toBe(10); // Back to original — full delta reversed
  });
});

describe('CompositeCommand', () => {
  it('should execute all sub-commands and undo in reverse order', () => {
    const doc = new CanvasDocument();
    const s1 = new RectShape(makeShapeId(), 0, 0, 50, 50);
    const s2 = new RectShape(makeShapeId(), 100, 0, 50, 50);
    doc.addShapeDirect(s1); doc.addShapeDirect(s2);

    const composite = new CompositeCommand('Move 2 shapes', [
      new MoveCommand(s1.id, 10, 0),
      new MoveCommand(s2.id, 10, 0),
    ]);

    composite.execute(doc);
    expect(s1.x).toBe(10); expect(s2.x).toBe(110);

    composite.undo(doc);
    expect(s1.x).toBe(0); expect(s2.x).toBe(100); // Both restored
  });
});

describe('CommandHistory', () => {
  it('should limit past entries to maxSize', () => {
    const doc = new CanvasDocument();
    const shape = new RectShape(makeShapeId(), 0, 0, 50, 50);
    doc.addShapeDirect(shape);
    const history = new CommandHistory(3); // Max 3 entries

    history.push(new MoveCommand(shape.id, 1, 0), doc);
    history.push(new MoveCommand(shape.id, 1, 0), doc);
    history.push(new MoveCommand(shape.id, 1, 0), doc);
    history.push(new MoveCommand(shape.id, 1, 0), doc); // 4th — oldest dropped

    expect(history.entries.length).toBe(3);
  });
});

Summary

Concept Role Canvas Studio Application
ICommand Encapsulates one reversible mutation MoveCommand, ResizeCommand, AddShapeCommand, DeleteShapeCommand
execute() / undo() Forward and reverse of the mutation Move: +dx/+dy → −dx/−dy; Resize: apply memento before/after
Memento Minimal pre-mutation snapshot for complex reversals ResizeCommand stores { x, y, width, height } before and after
mergeWith() Collapses continuous drag moves into one history entry 60 MoveCommands during a drag become one after merge
CompositeCommand Groups multiple mutations into one undo step Move five selected shapes = one CompositeCommand
CommandHistory Stack with push/undo/redo and max size enforcement 100-entry cap; new action clears redo stack
Delta vs Snapshot Commands store only the change, not the world MoveCommand: 2 numbers; not 1,000 shape objects

What's Next

Part 11 solves the final coupling problem: how a pure TypeScript class (CanvasDocument) with no React imports becomes the single source of truth for a React UI — using useSyncExternalStore to bridge the OOP engine and React's concurrent rendering model.

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#Design Patterns#Command Pattern#Memento Pattern#OOP#TypeScript#Undo/Redo#Frontend Architecture
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.