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.
Frontend Object-Oriented Architecture
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:
Under React 18's Concurrent Mode, React can interrupt and restart renders. The sequence that causes a 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:
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
doc.subscribe() must return a cleanup function — exactly what EventEmitter.on() returns in Part 9:
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.
The fix: cache the snapshot and invalidate it only when the underlying state changes:
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:
Usage — components subscribe to exactly what they need:
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:
6. Event Handler Wiring: Pointer Events → Domain Methods
The canvas component converts React's pointer events into domain-layer calls:
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
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+useSyncExternalStorebridge +ShapeRendererregistry + Web Worker offloading for 10,000-shape performance. The final architecture diagram shows every class, every connection, and every layer boundary.
This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.