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.
const [shapes, setShapes] = useState<ShapeData[]>([]);
const [history, setHistory] = useState<ShapeData[][]>([[]]);
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);
setHistory(newHistory);
setHistoryIndex(newHistory.length - 1);
setShapes(next);
}, [shapes, history, historyIndex]);
Three fundamental problems:
- Memory: 1,000 shapes × 100 history entries × ~200 bytes per shape = ~20MB for undo history alone
- Grouping:
moveShape called five times produces five undo steps — no way to batch them into one
- Determinism: snapshot must be cloned perfectly or aliasing bugs cause phantom mutations on undo
export interface ICommand {
readonly id: CommandId;
readonly name: string;
readonly timestamp: number;
execute(doc: CanvasDocument): void;
undo(doc: CanvasDocument): void;
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.
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);
}
mergeWith(previous: ICommand): boolean {
if (!(previous instanceof MoveCommand)) return false;
if ((previous as any).#shapeId !== this.#shapeId) return false;
(previous as any).#dx += this.#dx;
(previous as any).#dy += this.#dy;
return true;
}
}
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.
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:
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;
readonly #after: RectShapeMemento;
constructor(shapeId: ShapeId, before: RectShapeMemento, after: RectShapeMemento) {
this.id = makeCommandId();
this.name = 'Resize shape';
this.timestamp = Date.now();
this.#shapeId = shapeId;
this.#before = { ...before };
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);
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.
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); }
}
export class DeleteShapeCommand implements ICommand {
readonly id: CommandId;
readonly name: string;
readonly timestamp: number;
readonly #shapeId: ShapeId;
#deletedShape: IShape | null = null;
constructor(shapeId: ShapeId) {
this.id = makeCommandId();
this.name = 'Delete shape';
this.timestamp = Date.now();
this.#shapeId = shapeId;
}
execute(doc: CanvasDocument): void {
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);
}
}
Multiple commands grouped as a single undo entry:
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 {
for (let i = this.#commands.length - 1; i >= 0; i--) {
this.#commands[i].undo(doc);
}
}
}
Moving five selected shapes becomes:
const commands = selectedIds.map(id => new MoveCommand(id, dx, dy));
const grouped = new CompositeCommand(`Move ${selectedIds.size} shapes`, commands);
history.push(grouped);
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 {
command.execute(doc);
const last = this.#past[this.#past.length - 1];
if (last && command.mergeWith && command.mergeWith(last)) {
this.#future = [];
return;
}
this.#future = [];
this.#past.push(command);
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);
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 = [];
}
toJSON(): object[] {
return this.#past.map(cmd => ({
id: cmd.id,
name: cmd.name,
timestamp: cmd.timestamp,
}));
}
}
export class CanvasDocument extends EventEmitter<CanvasDocumentEvents> {
#shapes: Map<ShapeId, IShape> = new Map();
#selection: SelectionFSM = new SelectionFSM();
#history: CommandHistory = new CommandHistory(100);
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:
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));
}
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.
describe('MoveCommand', () => {
let doc: CanvasDocument;
let shape: RectShape;
beforeEach(() => {
doc = new CanvasDocument();
shape = new RectShape(makeShapeId(), 10, 20, 100, 50);
doc.addShapeDirect(shape);
});
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);
expect(shape.x).toBe(10);
});
});
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);
});
});
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);
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);
expect(history.entries.length).toBe(3);
});
});
| 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 |
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.