Domain objects are autonomous state machines with enforced invariants — they are not passive bags of data passed between controller functions. The canvas studio's selection system is inherently stateful: a user can be idle, mid-drag selecting, hovering over a resize handle, or multi-selecting with Shift held. Each state allows different transitions and different UI presentation. Modeling this with boolean flags — isSelecting, isResizing, isDragging, isHovering — creates an explosion of illegal state combinations: isSelecting: true and isResizing: true simultaneously is a bug that booleans cannot prevent. A Finite State Machine makes illegal states unrepresentable — not by convention but by structure.
The Observer Pattern solves the coupling problem: the toolbar, the properties panel, the canvas, and the layer list all need to react when selection changes. Without Observer, they are all directly wired together through prop drilling or shared context. With Observer, the CanvasDocument emits events and each subscriber reacts independently — no component knows about any other component.
Selection state with boolean flags at month six:
interface SelectionState {
selectedIds: Set<ShapeId>;
isIdle: boolean;
isSelecting: boolean;
isMoving: boolean;
isResizing: boolean;
isHovering: boolean;
isMultiSelecting: boolean;
resizeHandle: string | null;
}
state.isIdle = false;
state.isResizing = true;
state.isSelecting = true;
Illegal state combinations produce rendering bugs that are almost impossible to trace: the selection handles render at the wrong position because isMoving and isResizing are both true, and the handler that updates position is designed for isMoving only.
A Finite State Machine defines:
- A fixed set of states (one active at a time)
- A fixed set of transitions between states (triggered by events)
- Guards on transitions (conditions that must be true)
- Actions performed on entry/exit/transition
For the canvas selection system:
export type SelectionStateType =
| 'IDLE'
| 'HOVERED'
| 'SELECTED'
| 'MOVING'
| 'RESIZING'
| 'MARQUEE';
export interface SelectionContext {
selectedIds: Set<ShapeId>;
hoveredId: ShapeId | null;
resizeHandle: ResizeHandle | null;
marqueeRect: Rect | null;
dragDelta: { dx: number; dy: number };
}
export class SelectionFSM {
#state: SelectionStateType = 'IDLE';
#context: SelectionContext = {
selectedIds: new Set(),
hoveredId: null,
resizeHandle: null,
marqueeRect: null,
dragDelta: { dx: 0, dy: 0 },
};
get state(): SelectionStateType { return this.#state; }
get context(): Readonly<SelectionContext> { return { ...this.#context, selectedIds: new Set(this.#context.selectedIds) }; }
get hasSelection(): boolean { return this.#context.selectedIds.size > 0; }
get isMultiSelect(): boolean { return this.#context.selectedIds.size > 1; }
hoverShape(id: ShapeId): void {
if (this.#state !== 'IDLE' && this.#state !== 'HOVERED') return;
this.#context.hoveredId = id;
this.#transition('HOVERED');
}
hoverClear(): void {
if (this.#state !== 'HOVERED') return;
this.#context.hoveredId = null;
this.#transition('IDLE');
}
selectShape(id: ShapeId, additive = false): void {
if (additive) {
this.#context.selectedIds.add(id);
} else {
this.#context.selectedIds = new Set([id]);
}
this.#context.hoveredId = null;
this.#transition('SELECTED');
}
clearSelection(): void {
this.#context.selectedIds.clear();
this.#context.hoveredId = null;
this.#transition('IDLE');
}
beginMove(dragStart: Point): void {
if (this.#state !== 'SELECTED') return;
this.#context.dragDelta = { dx: 0, dy: 0 };
this.#transition('MOVING');
}
updateMove(dx: number, dy: number): void {
if (this.#state !== 'MOVING') return;
this.#context.dragDelta = { dx, dy };
}
commitMove(): void {
if (this.#state !== 'MOVING') return;
this.#context.dragDelta = { dx: 0, dy: 0 };
this.#transition('SELECTED');
}
beginResize(handle: ResizeHandle): void {
if (this.#state !== 'SELECTED') return;
this.#context.resizeHandle = handle;
this.#transition('RESIZING');
}
updateResize(dx: number, dy: number): void {
if (this.#state !== 'RESIZING') return;
this.#context.dragDelta = { dx, dy };
}
commitResize(): void {
if (this.#state !== 'RESIZING') return;
this.#context.resizeHandle = null;
this.#context.dragDelta = { dx: 0, dy: 0 };
this.#transition('SELECTED');
}
beginMarquee(startPoint: Point): void {
if (this.#state !== 'IDLE') return;
this.#context.marqueeRect = rect(startPoint.x, startPoint.y, 0, 0);
this.#transition('MARQUEE');
}
updateMarquee(currentPoint: Point): void {
if (this.#state !== 'MARQUEE' || !this.#context.marqueeRect) return;
const origin = { x: this.#context.marqueeRect.x, y: this.#context.marqueeRect.y };
this.#context.marqueeRect = rectFromPoints(origin as Point, currentPoint);
}
commitMarquee(shapeIds: ShapeId[]): void {
if (this.#state !== 'MARQUEE') return;
this.#context.marqueeRect = null;
if (shapeIds.length > 0) {
this.#context.selectedIds = new Set(shapeIds);
this.#transition('SELECTED');
} else {
this.#transition('IDLE');
}
}
cancelMarquee(): void {
if (this.#state !== 'MARQUEE') return;
this.#context.marqueeRect = null;
this.#transition('IDLE');
}
#transition(nextState: SelectionStateType): void {
this.#state = nextState;
}
}
The FSM makes illegal states unrepresentable:
beginMove() only works from SELECTED — impossible to move without a selection
beginMarquee() only works from IDLE — impossible to start a marquee while moving
updateResize() only updates when in RESIZING — all other states silently ignore it
The Observer Pattern defines a subject (CanvasDocument) that maintains a list of observers and notifies them of state changes. Observers subscribe to specific events and react independently — they do not know about each other.
type Listener<T = void> = (payload: T) => void;
export class EventEmitter<TEvents extends Record<string, unknown>> {
#listeners: Map<keyof TEvents, Set<Listener<any>>> = new Map();
on<K extends keyof TEvents>(event: K, listener: Listener<TEvents[K]>): () => void {
if (!this.#listeners.has(event)) this.#listeners.set(event, new Set());
this.#listeners.get(event)!.add(listener);
return () => this.#listeners.get(event)?.delete(listener);
}
protected emit<K extends keyof TEvents>(event: K, payload?: TEvents[K]): void {
this.#listeners.get(event)?.forEach(listener => listener(payload));
}
}
interface CanvasDocumentEvents {
'shapes:changed': void;
'selection:changed': { selectedIds: Set<ShapeId>; state: SelectionStateType };
'tool:changed': { tool: string };
'history:changed': { canUndo: boolean; canRedo: boolean };
'viewport:changed': { zoom: number; pan: Point };
}
export class CanvasDocument extends EventEmitter<CanvasDocumentEvents> {
#shapes: Map<ShapeId, IShape> = new Map();
#renderOrder: ShapeId[] = [];
#selection: SelectionFSM = new SelectionFSM();
#tools: ToolController;
#history: CommandHistory;
selectShape(id: ShapeId, additive = false): void {
if (!this.#shapes.has(id)) throw new DomainError(`Shape ${id} not found`);
this.#selection.selectShape(id, additive);
this.emit('selection:changed', {
selectedIds: this.#selection.context.selectedIds,
state: this.#selection.state,
});
}
clearSelection(): void {
this.#selection.clearSelection();
this.emit('selection:changed', {
selectedIds: new Set(),
state: 'IDLE',
});
}
addShape(shape: IShape): void {
this.#shapes.set(shape.id, shape);
this.#renderOrder.push(shape.id);
this.emit('shapes:changed');
}
moveSelection(dx: number, dy: number): void {
if (this.#selection.state !== 'MOVING') return;
for (const id of this.#selection.context.selectedIds) {
this.#shapes.get(id)?.move(dx, dy);
}
this.emit('shapes:changed');
}
subscribe(listener: () => void): () => void {
const unsubShapes = this.on('shapes:changed', listener);
const unsubSelection = this.on('selection:changed', listener);
const unsubHistory = this.on('history:changed', listener);
const unsubViewport = this.on('viewport:changed', listener);
return () => { unsubShapes(); unsubSelection(); unsubHistory(); unsubViewport(); };
}
getSnapshot(): CanvasSnapshot {
const selCtx = this.#selection.context;
return {
shapes: this.#renderOrder.map(id => this.#shapes.get(id)!.toData()),
selectedIds: selCtx.selectedIds,
selectionState: this.#selection.state,
marqueeRect: selCtx.marqueeRect,
canUndo: this.#history.canUndo,
canRedo: this.#history.canRedo,
zoom: this.#viewport.zoom,
pan: this.#viewport.pan,
};
}
}
Some parts of the UI only need specific events. The HistoryPanel only cares about history:changed:
function HistoryPanel({ doc }: { doc: CanvasDocument }) {
const [historyState, setHistoryState] = useState({ canUndo: false, canRedo: false });
useEffect(() => {
return doc.on('history:changed', ({ canUndo, canRedo }) => {
setHistoryState({ canUndo, canRedo });
});
}, [doc]);
return (
<div>
<button disabled={!historyState.canUndo} onClick={() => doc.undo()}>Undo</button>
<button disabled={!historyState.canRedo} onClick={() => doc.redo()}>Redo</button>
</div>
);
}
HistoryPanel does not re-render when shapes are moved — it only re-renders when history:changed fires. No useMemo. No React.memo. The granularity is free.
describe('SelectionFSM', () => {
let fsm: SelectionFSM;
beforeEach(() => { fsm = new SelectionFSM(); });
it('should start in IDLE state', () => {
expect(fsm.state).toBe('IDLE');
expect(fsm.hasSelection).toBe(false);
});
it('should transition IDLE → SELECTED on selectShape()', () => {
const id = unsafeShapeId('rect-1');
fsm.selectShape(id);
expect(fsm.state).toBe('SELECTED');
expect(fsm.context.selectedIds.has(id)).toBe(true);
});
it('should ignore beginMove() when IDLE — guard respected', () => {
fsm.beginMove(point(0, 0));
expect(fsm.state).toBe('IDLE');
});
it('should transition SELECTED → MOVING → SELECTED on commit', () => {
fsm.selectShape(unsafeShapeId('rect-1'));
expect(fsm.state).toBe('SELECTED');
fsm.beginMove(point(0, 0));
expect(fsm.state).toBe('MOVING');
fsm.commitMove();
expect(fsm.state).toBe('SELECTED');
});
it('should prevent simultaneous MOVING and RESIZING — guards enforce exclusivity', () => {
fsm.selectShape(unsafeShapeId('rect-1'));
fsm.beginMove(point(0, 0));
expect(fsm.state).toBe('MOVING');
fsm.beginResize('nw');
expect(fsm.state).toBe('MOVING');
});
it('should add to selection when additive=true', () => {
const id1 = unsafeShapeId('rect-1');
const id2 = unsafeShapeId('rect-2');
fsm.selectShape(id1);
fsm.selectShape(id2, true);
expect(fsm.context.selectedIds.size).toBe(2);
expect(fsm.isMultiSelect).toBe(true);
});
});
Zero React. Zero DOM. The FSM is a pure TypeScript class — 12 tests run in under 5ms.
| Concept |
Problem Solved |
Canvas Studio Application |
| Finite State Machine |
Boolean flag explosion; illegal state combinations |
SelectionFSM: IDLE / HOVERED / SELECTED / MOVING / RESIZING / MARQUEE |
| Guards on transitions |
Illegal transitions silently rejected — no defensive if chains |
beginMove() only works from SELECTED; beginMarquee() only from IDLE |
| Observer Pattern |
Direct coupling between components — props/context shared everywhere |
CanvasDocument emits typed events; toolbar/panel/canvas subscribe independently |
EventEmitter.on() |
Memory-leak-prone removeListener chains |
on() returns unsubscribe function — useEffect(() => doc.on(...), [doc]) |
| Fine-grained subscriptions |
Full snapshot re-render on any state change |
HistoryPanel subscribes only to history:changed — isolated re-renders |
Part 10 builds the Command and Memento patterns — implementing ICommand, MoveCommand, ResizeCommand, and CommandHistory to provide enterprise-grade undo/redo that survives multi-step operations, grouped commands, and serializable session history.