Siddhant Deval
Siddhant Deval
frontend5 min read

Finite State Machines & The Observer Pattern: Decoupling Tools and Selection

Managing multi-modal canvas interactions with boolean flags produces impossible UI states: combining isDragging, isSelecting, and isResizing creates 16 permutations, half of which are invalid ghost states. Finite State Machines and the typed Observer pattern create bulletproof interaction pipelines with zero illegal state combinations.

Finite State Machines & The Observer Pattern: Decoupling Tools and Selection

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.


1. The Boolean Flag Explosion

Selection state with boolean flags at month six:

TYPESCRIPT
// ❌ Boolean flags — illegal combinations are not prevented
interface SelectionState {
  selectedIds: Set<ShapeId>;
  isIdle: boolean;
  isSelecting: boolean;        // True during marquee drag
  isMoving: boolean;           // True during shape drag
  isResizing: boolean;         // True during handle drag
  isHovering: boolean;         // True when hovering over a shape
  isMultiSelecting: boolean;   // True when shift is held
  resizeHandle: string | null; // Which handle: 'nw' | 'ne' | 'sw' | 'se' | null
}

// Somewhere in the codebase:
state.isIdle = false;
state.isResizing = true;
state.isSelecting = true; // ← Bug: isSelecting and isResizing are simultaneously true
// The UI now shows both marquee rectangle AND resize handles — impossible state

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.


2. Finite State Machine: Only One State at a Time

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:

TYPESCRIPT
// src/domain/selection/SelectionState.ts
export type SelectionStateType =
  | 'IDLE'          // Nothing selected, no interaction
  | 'HOVERED'       // Mouse over a shape — not selected
  | 'SELECTED'      // One or more shapes selected, idle
  | 'MOVING'        // Dragging selected shapes
  | 'RESIZING'      // Dragging a resize handle
  | 'MARQUEE';      // Dragging a selection rectangle

export interface SelectionContext {
  selectedIds: Set<ShapeId>;
  hoveredId: ShapeId | null;
  resizeHandle: ResizeHandle | null;   // 'nw' | 'ne' | 'sw' | 'se' | 'n' | 's' | 'e' | 'w'
  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; }

  // ── Events ──

  hoverShape(id: ShapeId): void {
    if (this.#state !== 'IDLE' && this.#state !== 'HOVERED') return; // Guard: only from idle/hovered
    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; // Guard: must have a selection to move
    this.#context.dragDelta = { dx: 0, dy: 0 };
    this.#transition('MOVING');
  }

  updateMove(dx: number, dy: number): void {
    if (this.#state !== 'MOVING') return; // Guard: must be in MOVING state
    this.#context.dragDelta = { dx, dy };
    // No state transition — stays in MOVING
  }

  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; // Guard: must have selection
    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; // Guard: can only marquee from idle
    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

3. The Observer Pattern: Decoupled Event Propagation

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.

3.1 The EventEmitter Base

TYPESCRIPT
// src/domain/shared/EventEmitter.ts
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);
    // Returns an unsubscribe function — no removeListener calls needed
    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));
  }
}

3.2 CanvasDocument as the Observable Subject

TYPESCRIPT
// src/domain/canvas/CanvasDocument.ts
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;

  // ── Selection delegation ──
  selectShape(id: ShapeId, additive = false): void {
    if (!this.#shapes.has(id)) throw new DomainError(`Shape ${id} not found`);
    this.#selection.selectShape(id, additive);
    // Emit event — all subscribers notified
    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',
    });
  }

  // ── Shape operations ──
  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');
  }

  // ── Snapshot (for useSyncExternalStore) ──
  // One subscribe() method — useSyncExternalStore compatible
  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,
    };
  }
}

3.3 Fine-Grained Observer Subscriptions

Some parts of the UI only need specific events. The HistoryPanel only cares about history:changed:

TYPESCRIPT
// Subscribes to one specific event — not the full snapshot
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.


4. Testing the FSM

TYPESCRIPT
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'); // Guard prevented transition
  });

  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'); // Guard: beginResize only works from SELECTED
    expect(fsm.state).toBe('MOVING'); // State unchanged — resize was silently rejected
  });

  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); // Shift+click
    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.


Summary

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

What's Next

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.

Research & Synthesis Note

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

#State Machines#Observer Pattern#OOP#TypeScript#Design Patterns#Canvas#Frontend Architecture
Siddhant Deval

Written by Siddhant Deval

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