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.
| Hook | Subscription | Re-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:
-
First render.
getSnapshotis invoked synchronously inside render and callsgraph.read(node). No subscription is registered yet. -
Commit (in the React sense). React invokes the
subscribe callback, which in turn calls
graph.subscribe(node, cb). The engine returns anUnsubscribehandle, which React stashes for cleanup. -
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
getSnapshotagain, compares with the previous value, and schedules a re-render only if it has changed. -
Unmount. React invokes the
Unsubscribehandle 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>:
- Double-mount does not produce duplicate subscriptions. A single commit after mount triggers at most one StrictMode-paired re-render.
-
A selector whose result is stable under
Object.isdoes not loop, even with double-invocation. -
act(...)-wrapped commits flush cleanly with no leaked microtasks.
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:
-
Stronger: the per-node subscriber will only
fire when the committed value is
Object.is-distinct from the previous committed value. So you will not be woken spuriously. - Weaker: two reads at the same commit may return distinct JS references for the same logical value. So you must compare by content, not by identity, in any code that reads twice and compares.
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 fixedGraphTimeis preserved, reference identity across commits is backend-dependent. The always-on guard is theread-no-identity-contract.property.test.ts1000-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:
-
No "stable selector" memoisation built in. If
your selector returns a fresh array or object on every call,
useCauslwill render every commit becauseObject.isnever matches. UseuseCauslShallowfor shallow-equal projections, or push the projection into agraph.derivedso the engine memoises it for you. -
No automatic concurrent-mode tearing protection beyond
useSyncExternalStore. The engine commits synchronously and React's external-store integration handles the tear-free guarantee. If you callgraph.commitfrom inside a transition, React batches the resulting render normally. -
No built-in suspense for unresolved values.
For async resource lifecycles, use
useCauslSuspensewith aSuspendableResource<T>from@causl/sync— that hook is documented in Part 7.
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.