Siddhant Deval
Siddhant Deval
frontend5 min read

The Reactive Bridge: Connecting OOP Engines to React via useSyncExternalStore

A domain engine must remain 100% framework-agnostic. React 18/19's useSyncExternalStore provides the official concurrent-safe reactive bridge that subscribes to domain mutations with zero tearing and surgical render performance — replacing ad-hoc useEffect listeners that cause ghost updates and cascade re-renders.

The Reactive Bridge: Connecting OOP Engines to React via useSyncExternalStore

Domain objects are autonomous state machines with enforced invariants — they are not passive bags of data passed between controller functions. By Part 10, the canvas studio's CanvasDocument is a fully capable OOP engine: it manages shape state, enforces invariants, tracks selection via a finite state machine, executes commands through the history system, and emits typed Observer events. It imports nothing from React. It is pure TypeScript.

The final coupling problem: React does not know this object exists. React re-renders in response to useState, useReducer, and context changes — not in response to an external object's mutation. The naive solution is useEffect with a subscription that calls setState. This works in simple cases but breaks under React 18's Concurrent Mode: the external store can change between the time React reads the store value and the time it commits the render — producing a tear where different components in the same render show different versions of the store.

useSyncExternalStore is React 18's official API for subscribing to external stores safely under concurrent rendering. This article implements the complete reactive bridge between CanvasDocument and React — with tear prevention, snapshot stability, server rendering compatibility, and selector-based partial subscriptions.


1. Why useEffect + useState Breaks Under Concurrency

The classic pattern:

TYPESCRIPT
// ❌ Unsafe under React 18 Concurrent Mode
function useCanvasDocument(doc: CanvasDocument) {
  const [snapshot, setSnapshot] = useState(() => doc.getSnapshot());

  useEffect(() => {
    const unsub = doc.subscribe(() => setSnapshot(doc.getSnapshot()));
    return unsub;
  }, [doc]);

  return snapshot;
}

Under React 18's Concurrent Mode, React can interrupt and restart renders. The sequence that causes a tear:

1. React starts rendering CanvasComponent — reads snapshot (version A)
2. External event fires — CanvasDocument mutates to version B
3. React renders a child component — reads snapshot via useEffect-managed state
4. BUT: useEffect hasn't fired yet (it runs after paint)
5. Child reads version A; React commits both — UI shows inconsistent state (tear)

This is not a theoretical edge case. It manifests in canvas editors when pointer events fire rapidly during drag — the shape position shown in the properties panel lags behind the position shown on the canvas.


2. useSyncExternalStore: The Correct API

useSyncExternalStore was introduced in React 18 specifically for external stores. It takes three arguments:

TYPESCRIPT
useSyncExternalStore(
  subscribe: (onStoreChange: () => void) => () => void,
  getSnapshot: () => Snapshot,
  getServerSnapshot?: () => Snapshot,  // Optional: for SSR
): Snapshot

React guarantees:

  • getSnapshot() is called synchronously during the render — consistent read
  • If the store changes between two getSnapshot() calls for the same render, React re-renders synchronously — no tear possible
  • subscribe() is called once per mount; the returned cleanup is called on unmount

3. The Full Reactive Bridge Implementation

3.1 useCanvasDocument — Full Snapshot Subscription

TYPESCRIPT
// src/hooks/useCanvasDocument.ts
import { useSyncExternalStore } from 'react';
import type { ICanvasDocument, CanvasSnapshot } from '../domain/canvas/ICanvasDocument';

/**
 * Subscribes to ALL CanvasDocument changes.
 * Use for components that render the full canvas state.
 */
export function useCanvasDocument(doc: ICanvasDocument): CanvasSnapshot {
  return useSyncExternalStore(
    (onStoreChange) => doc.subscribe(onStoreChange), // subscribe
    () => doc.getSnapshot(),                          // getSnapshot (client)
    () => doc.getServerSnapshot(),                    // getServerSnapshot (SSR)
  );
}

doc.subscribe() must return a cleanup function — exactly what EventEmitter.on() returns in Part 9:

TYPESCRIPT
// In CanvasDocument:
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(); };
}

3.2 Snapshot Stability: Reference Equality Matters

React bails out of re-renders when the snapshot reference is the same as the previous render. If getSnapshot() creates a new object every call, React re-renders on every external notification — even if the data did not change.

TYPESCRIPT
// ❌ Unstable snapshot — new object every call → React always re-renders
getSnapshot(): CanvasSnapshot {
  return {
    shapes: this.#renderOrder.map(id => this.#shapes.get(id)!.toData()), // New array every call
    selectedIds: new Set(this.#selection.context.selectedIds),            // New Set every call
    // ...
  };
}

The fix: cache the snapshot and invalidate it only when the underlying state changes:

TYPESCRIPT
// ✅ Cached snapshot — same reference returned if state unchanged
export class CanvasDocument extends EventEmitter<CanvasDocumentEvents> {
  #snapshot: CanvasSnapshot | null = null; // null = dirty, needs recompute

  // Called by EventEmitter whenever any state changes
  protected emit<K extends keyof CanvasDocumentEvents>(event: K, payload?: any): void {
    this.#snapshot = null; // Invalidate cache
    super.emit(event, payload);
  }

  getSnapshot(): CanvasSnapshot {
    if (this.#snapshot) return this.#snapshot; // React gets the same reference → bail-out

    const selCtx = this.#selection.context;
    this.#snapshot = {
      shapes:         this.#renderOrder.map(id => this.#shapes.get(id)!.toData()),
      selectedIds:    new Set(selCtx.selectedIds),
      selectionState: this.#selection.state,
      marqueeRect:    selCtx.marqueeRect,
      preview:        this.#preview,
      canUndo:        this.#history.canUndo,
      canRedo:        this.#history.canRedo,
      zoom:           this.#viewport.zoom,
      pan:            this.#viewport.pan,
    };
    return this.#snapshot;
  }

  getServerSnapshot(): CanvasSnapshot {
    // On the server, return an empty stable snapshot — no shape state
    return {
      shapes: [], selectedIds: new Set(), selectionState: 'IDLE',
      marqueeRect: null, preview: null,
      canUndo: false, canRedo: false,
      zoom: 1, pan: { x: 0, y: 0 },
    };
  }
}

With caching: if the user scrolls (viewport change) but shapes are unchanged, the shapes array in the snapshot is the same reference as the previous render. React's <ShapeRenderer> components wrapped in React.memo bail out — zero re-renders for the shape layer on viewport change.


4. Selector-Based Partial Subscriptions

Different components only need slices of the full snapshot. A generic useSelector hook extracts a slice and memoizes it — re-rendering only when the selected slice changes:

TYPESCRIPT
// src/hooks/useCanvasSelector.ts
import { useSyncExternalStore } from 'react';
import type { ICanvasDocument, CanvasSnapshot } from '../domain/canvas/ICanvasDocument';

/**
 * Subscribes to a selected slice of CanvasDocument state.
 * Re-renders ONLY when the selected slice changes (by reference equality).
 */
export function useCanvasSelector<T>(
  doc: ICanvasDocument,
  selector: (snapshot: CanvasSnapshot) => T,
): T {
  return useSyncExternalStore(
    (onStoreChange) => doc.subscribe(onStoreChange),
    () => selector(doc.getSnapshot()),
    () => selector(doc.getServerSnapshot()),
  );
}

Usage — components subscribe to exactly what they need:

TYPESCRIPT
// ToolbarComponent — only re-renders when canUndo/canRedo/activeTool changes
function Toolbar({ doc }: { doc: ICanvasDocument }) {
  const canUndo    = useCanvasSelector(doc, s => s.canUndo);
  const canRedo    = useCanvasSelector(doc, s => s.canRedo);
  const activeTool = useCanvasSelector(doc, s => s.activeTool);

  return (
    <div className="toolbar">
      <button disabled={!canUndo} onClick={() => doc.undo()}>Undo</button>
      <button disabled={!canRedo} onClick={() => doc.redo()}>Redo</button>
      <ToolPalette activeTool={activeTool} doc={doc} />
    </div>
  );
}
// Does NOT re-render when shapes are moved — canUndo/canRedo/activeTool unchanged
TYPESCRIPT
// PropertiesPanel — only re-renders when selectedIds changes
function PropertiesPanel({ doc }: { doc: ICanvasDocument }) {
  const selectedIds = useCanvasSelector(doc, s => s.selectedIds);
  const snapshot    = useCanvasSelector(doc, s => s);

  if (selectedIds.size === 0) return <EmptyState />;

  const selectedShapes = snapshot.shapes.filter(s => selectedIds.has(s.id as ShapeId));
  return <ShapeProperties shapes={selectedShapes} doc={doc} />;
}
TYPESCRIPT
// Canvas view — subscribes to shapes and preview only
function CanvasView({ doc }: { doc: ICanvasDocument }) {
  const shapes  = useCanvasSelector(doc, s => s.shapes);  // Re-renders on shape change
  const preview = useCanvasSelector(doc, s => s.preview); // Re-renders on preview change
  const zoom    = useCanvasSelector(doc, s => s.zoom);
  const pan     = useCanvasSelector(doc, s => s.pan);

  return (
    <svg
      style={{ transform: `scale(${zoom}) translate(${pan.x}px, ${pan.y}px)` }}
      // pointer events → forward to ToolController
    >
      {shapes.map(s => <ShapeRenderer key={s.id} data={s} />)}
      {preview && <PreviewRenderer preview={preview} />}
    </svg>
  );
}
// Does NOT re-render when undo history changes — shapes array unchanged
Crucial Requirement

Selector stability: useCanvasSelector(doc, s => s.selectedIds) — if selectedIds is a new Set(...) on every getSnapshot() call, the selector always returns a new object reference → always re-renders. The snapshot caching in §3.2 is mandatory for selector bail-out to work. Alternative: compare by size and contents, or use a stable selectedIdsString primitive selector instead.


5. The ToolController Reactive Bridge

The ToolController is a separate domain object (Part 7). It also needs a reactive bridge:

TYPESCRIPT
// src/hooks/useToolController.ts
export function useActiveTool(tools: ToolController): string {
  return useSyncExternalStore(
    (onChange) => tools.on('toolChanged', onChange),
    () => tools.activeTool.name,
    () => 'select', // SSR default
  );
}
TYPESCRIPT
// In ToolController — subscribe compatible with useSyncExternalStore
on(event: 'toolChanged', listener: () => void): () => void {
  this.#listeners.add(listener);
  return () => this.#listeners.delete(listener);
}

6. Event Handler Wiring: Pointer Events → Domain Methods

The canvas component converts React's pointer events into domain-layer calls:

TYPESCRIPT
// src/components/Canvas.tsx — complete reactive canvas component
import { useRef, useCallback } from 'react';
import { useCanvasSelector } from '../hooks/useCanvasSelector';
import type { ICanvasDocument } from '../domain/canvas/ICanvasDocument';
import type { ToolController } from '../domain/tools/ToolController';

interface CanvasProps {
  doc: ICanvasDocument;
  tools: ToolController;
}

export function Canvas({ doc, tools }: CanvasProps) {
  const svgRef  = useRef<SVGSVGElement>(null);
  const shapes  = useCanvasSelector(doc, s => s.shapes);
  const preview = useCanvasSelector(doc, s => s.preview);
  const zoom    = useCanvasSelector(doc, s => s.zoom);
  const pan     = useCanvasSelector(doc, s => s.pan);

  const toToolEvent = useCallback((e: React.PointerEvent<SVGSVGElement>) => ({
    point:    svgPoint(e, svgRef.current!),
    shiftKey: e.shiftKey,
    ctrlKey:  e.ctrlKey,
    altKey:   e.altKey,
    pressure: e.pressure,
  }), []);

  return (
    <svg
      ref={svgRef}
      width="100%" height="100%"
      style={{ cursor: tools.cursor }}
      onPointerDown={e  => { e.currentTarget.setPointerCapture(e.pointerId); tools.onPointerDown(toToolEvent(e)); }}
      onPointerMove={e  => tools.onPointerMove(toToolEvent(e))}
      onPointerUp={e    => { e.currentTarget.releasePointerCapture(e.pointerId); tools.onPointerUp(toToolEvent(e)); }}
      onKeyDown={e      => tools.handleKeyDown(e.key)}
      tabIndex={0}      // Required for keyDown events on SVG
    >
      <g transform={`scale(${zoom}) translate(${pan.x} ${pan.y})`}>
        {shapes.map(s => (
          <ShapeRenderer key={s.id} data={s} />
        ))}
        {preview && <PreviewRenderer preview={preview} />}
      </g>
    </svg>
  );
}

setPointerCapture ensures that onPointerMove and onPointerUp fire even if the pointer leaves the SVG element during a drag — critical for smooth shape movement.


7. The Complete Dependency Graph

The domain layer (CanvasDocument, ToolController, CommandHistory, SelectionFSM) is pure TypeScript — zero React imports. The bridge layer (useCanvasSelector, useActiveTool) connects them to React's rendering cycle. Components subscribe to slices; unrelated changes do not trigger re-renders.


8. Testing the Bridge

TYPESCRIPT
// tests/unit/hooks/useCanvasSelector.test.ts
import { renderHook, act } from '@testing-library/react';
import { useCanvasSelector } from '../../../src/hooks/useCanvasSelector';
import { CanvasDocument } from '../../../src/domain/canvas/CanvasDocument';

describe('useCanvasSelector', () => {
  it('should return initial snapshot from getSnapshot()', () => {
    const doc = new CanvasDocument();
    const { result } = renderHook(() => useCanvasSelector(doc, s => s.shapes));
    expect(result.current).toHaveLength(0);
  });

  it('should re-render when selected slice changes', async () => {
    const doc = new CanvasDocument();
    const { result } = renderHook(() => useCanvasSelector(doc, s => s.canUndo));

    expect(result.current).toBe(false);

    act(() => {
      const shape = new RectShape(makeShapeId(), 0, 0, 100, 50);
      doc.execute(new AddShapeCommand(shape));
    });

    expect(result.current).toBe(true); // canUndo changed — re-render triggered
  });

  it('should NOT re-render when unselected slice changes', async () => {
    const doc = new CanvasDocument();
    let renderCount = 0;

    renderHook(() => {
      renderCount++;
      return useCanvasSelector(doc, s => s.canUndo); // Only subscribes to canUndo
    });

    const initialCount = renderCount;

    act(() => {
      // This only changes 'zoom' — canUndo unchanged
      doc.setZoom(2);
    });

    expect(renderCount).toBe(initialCount); // No re-render — canUndo still false
  });
});

Summary

Concept Problem Solved Implementation
useSyncExternalStore Tearing under React 18 Concurrent Mode Official React API — reads store synchronously during render
Snapshot caching getSnapshot() returning new objects every call → always re-render #snapshot cache, invalidated by emit()
getServerSnapshot SSR hydration mismatch Returns stable empty snapshot — no shape state on server
useCanvasSelector Full-snapshot subscription triggers unnecessary re-renders Selector extracts slice; re-renders only when slice reference changes
setPointerCapture Pointer events lost when mouse leaves SVG during drag Captured pointer reports events to SVG regardless of position
Domain ↔ React boundary Domain objects import nothing from React useSyncExternalStore is the ONLY React touchpoint — in a hook file, not a domain file

What's Next

Part 12 is the production capstone — assembling all 11 patterns into the complete, running vector studio: CanvasDocument + SelectionFSM + ToolController + CommandHistory + useSyncExternalStore bridge + ShapeRenderer registry + Web Worker offloading for 10,000-shape performance. The final architecture diagram shows every class, every connection, and every layer boundary.

Research & Synthesis Note

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

#React#OOP#TypeScript#useSyncExternalStore#Reactive Programming#Architecture#React 18
Siddhant Deval

Written by Siddhant Deval

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