Part 5: React Integration

So far in this tutorial we have been driving the graph from plain TypeScript — constructing a Graph, declaring inputs and deriveds, opening commits by hand, and reading values back through graph.read(). Real applications need a view layer. This part introduces @causl/react, the official React 18+ binding, and walks through the per-node subscription hook useCauslNode in detail — mount and unmount semantics, StrictMode safety, a reference-identity hazard you must plan around, and the typed-array projection hook. Everything here runs on the default pure-TS engine from createCausl(); the zero-copy typed-array path is an optional Enterprise upgrade Enterprise that we flag where it applies.

If you have used Redux with useSelector, or Jotai with useAtomValue, the shape will feel familiar. The differences are in the guarantees: Causl's engine fires per-node subscribers exactly once per commit during which the node's value changed, so the React layer does not have to second-guess the engine. That changes how you should structure components.

1. Install and provide the graph

Add the binding alongside the core engine and React itself:

pnpm add @causl/core @causl/react react react-dom

The graph itself is host-owned. The provider routes it through context — it never constructs or disposes the graph, so a re-render of the provider cannot accidentally throw your state away. Build the graph once at module scope (or inside your app shell's useMemo if you need per-route isolation) and pass it to <CauslProvider>:

import { createCausl } from '@causl/core' import { CauslProvider } from '@causl/react' const graph = createCausl() const counter = graph.input('counter', 0) const doubled = graph.derived('doubled', g => g(counter) * 2) export function App() { return ( <CauslProvider graph={graph}> <Counter /> </CauslProvider> ) }
Detailed explanation: why the provider does not own the graph. Two reasons. First, the graph's lifecycle is wider than any one React tree — devtools, persistence adapters, sync engines, and server-side hydration all hold references that outlive any <CauslProvider>. Second, React's reconciler is allowed to unmount and remount your subtree at any time (route transitions, suspense boundaries, StrictMode dev checks). If the provider owned the graph, every unmount would risk destroying engine state. Routing a host-owned handle through context sidesteps the question entirely.

2. Read a single node with useCauslNode

The simplest possible read: subscribe to one node, get its current committed value, re-render when (and only when) that node's value changes.

import { useCauslNode } from '@causl/react' function Counter() { const value = useCauslNode(counter) return <span data-testid="value">{value}</span> }

Notice what is not in this component. There is no useEffect, no manual subscribe / unsubscribe dance, no cleanup function. The hook is built on React's useSyncExternalStore, so mount, unmount, and re-subscription are handled by the runtime. The hook's subscribe callback closes over the graph and node references via useCallback, so React only re-subscribes when the engine handle or the node identity genuinely changes — typically never, during the lifetime of a component.

How it compares to useCausl(selector)

@causl/react ships two read paths and the distinction matters for performance at scale.

HookSubscriptionRe-renders when
useCauslNode(node) graph.subscribe(node, cb) The engine fires for that node only — already deduped by Object.is inside the engine.
useCausl(selector) graph.subscribeCommits(cb) Every commit wakes the selector; React skips the render only when the selector's return is Object.is-equal to the previous value.

Rule of thumb: reach for useCauslNode when a component reads one node. Reach for useCausl when you need to project across multiple nodes into a single derived value. Wrapping the projection in a graph.derived(...) and then subscribing to the derived node with useCauslNode is usually the cleanest answer; you get memoisation at the engine level instead of inside React.

3. Mount and unmount semantics

useCauslNode's lifecycle is delegated to useSyncExternalStore, but the contract bears spelling out so you can reason about ordering:

  1. First render. getSnapshot is invoked synchronously inside render and calls graph.read(node). No subscription is registered yet.
  2. Commit (in the React sense). React invokes the subscribe callback, which in turn calls graph.subscribe(node, cb). The engine returns an Unsubscribe handle, which React stashes for cleanup.
  3. Update. When a Causl commit changes the observed node, the engine fires the subscribe callback during Phase G subscriber dispatch (see SPEC §5.1). React then calls getSnapshot again, compares with the previous value, and schedules a re-render only if it has changed.
  4. Unmount. React invokes the Unsubscribe handle the engine returned. From that moment, this component is no longer in the per-node subscriber list — subsequent commits do not pay any per-subscriber cost for it.

The important consequence: the engine never holds a reference to the React component, only to the subscribe callback. When React unmounts and the callback is released, there is nothing left for the engine to hold. useCauslNode does not leak.

4. StrictMode handling

In React 18 development mode, <StrictMode> mounts each component twice and runs each effect twice. The naive hook would register a subscription, immediately tear it down, register a second one, and have you wondering whether you are seeing double notifications. useCauslNode sidesteps this entirely because useSyncExternalStore is designed for the discipline: the subscribe and getSnapshot callbacks are pure functions of (graph, node), and the engine's Object.is cutoff already prevents duplicate notifications.

The package's regression suite (see packages/react/test/strictMode.test.tsx) enforces three properties under <StrictMode>:

In practice this means: write your component the way you would with any other useSyncExternalStore-backed library, and StrictMode just works. There is no special escape hatch and no need to suppress dev-only warnings.

5. The H1 hazard: read() reference identity is not contractually guaranteed

Hazard. Per SPEC §15.1, graph.read(node) is not required to return the same JavaScript reference on successive calls — even when the committed value is the same. Two reads from the same commit may return reference-equal values, and the default pure-TS engine typically does, but adopters must not rely on it. The contract is intentionally weak here so that alternative backends stay conformant (SPEC §15.1 amendment #1124).

This is the H1 hazard, and it is the single most important thing to internalise when porting code from libraries that do guarantee reference identity (Redux Toolkit's selectors with memoisation; Jotai's atoms). The contract Causl gives you is stronger in one direction and weaker in another:

What this means in practice

For scalar values (number, string, boolean) the hazard is invisible: scalars compare structurally and by identity simultaneously. The hazard shows up with object and array values:

// BAD — relies on reference identity that is not contractually guaranteed. function Row({ id }: { id: string }) { const row = useCauslNode(rowsById[id]) // {name, qty} const lastRow = useRef(row) if (row !== lastRow.current) { // may fire spuriously: read() identity is not guaranteed analytics.track('row-changed', id) lastRow.current = row } return <td>{row.name}</td> } // GOOD — drive change detection through the engine, not through reference identity. function Row({ id }: { id: string }) { const row = useCauslNode(rowsById[id]) // The engine already deduped by Object.is at the commit boundary; the // component renders exactly when the row's *committed value* changed. return <td>{row.name}</td> }

If you genuinely need a "fire side-effect when the value changes" hook, use graph.subscribe(node, cb) directly inside a useEffect — the engine guarantees the subscriber fires only on real changes, and you avoid the reference-identity trap entirely.

Detailed explanation: why the spec was amended. Earlier drafts of the spec were silent on reference identity, and the pure-TS engine's implementation happened to return the same reference because compound values were stored directly. Promoting a weaker, backend-independent guarantee into the spec lets alternative backends stay conformant without a breaking change for adopters who follow the documented contract. The governing contract is the SPEC §15.1 amendment (#1124): graph.read(node) does not contractually return the same JavaScript reference across calls; value identity at a fixed GraphTime is preserved, reference identity across commits is backend-dependent. The always-on guard is the read-no-identity-contract.property.test.ts 1000-trial property that wraps a backend in a fresh-copy decorator so no internal engine code can accidentally rely on identity. The default pure-TS engine returns identical references trivially, so following the content-comparison rule above keeps your code correct on every backend.

6. Typed-array projections

For components that visualise bulk numeric data — price ladders, audio buffers, spreadsheet ranges, image histograms — passing number[] through React introduces both the H1 hazard and an unbounded copy on every commit. The useCauslTypedArrayNode hook is the answer:

import { useCauslTypedArrayNode } from '@causl/react' const prices = graph.input('prices', new Float64Array(1024)) function Ladder() { const view = useCauslTypedArrayNode(prices, Float64Array) // `view` is a Float64Array, reference-stable across renders until // the next commit that changes `prices`. Safe to feed to React.memo // children that compare by Object.is. return <Heatmap data={view} /> }

On the default pure-TS engine the hook reads via graph.read(node): if the committed value is already an instance of the requested constructor, it is returned verbatim, otherwise the hook does a one-shot ctor.from(value) copy. A cached view reference is re-used across renders for the same commit, so React.memo consumers comparing by identity still skip work.

The hook preserves a stability contract you can rely on regardless of backend: the view returned for commit N is Object.is-equal to itself across every render between commit N and commit N+1, and a fresh reference is returned after any commit that changes the node. Adopters may rely on Object.is(viewA, viewB) === false as a signal that the underlying numeric data changed.

7. Honest limits

A few things the React binding deliberately does not do, so you can plan accordingly:

What's next

You can now bind a Causl graph to a React component tree, read single nodes efficiently, structure components to avoid the H1 reference-identity hazard, and reach for typed-array projections when bulk numeric data needs to flow through the view layer. Part 6 picks up dispatch and the Model-View-Update pattern with defineMsgs, createUpdate, and useDispatch — the front door for state mutation in a React app.

← Part 4: Statecharts Part 6: Saving & Loading →