React Integration — Deep Dive
This page is the long-form companion to the React quick-start. It explains how
@causl/react binds a Causl Graph to React's rendering and
reconciliation model: which hook subscribes to what, why useSyncExternalStore
is the substrate under every hook, how Suspense and SSR hydration cooperate with the
engine's GraphTime-consistent reads, and which adopter pitfalls
(StrictMode, fresh-literal selectors, the H1 reference-identity hazard) you should
design around from day one rather than discover in production.
If you are looking for "five lines that render a counter," start with the
React quick-start. This page assumes
you already have a <CauslProvider> wrapping your tree and at least
one component reading from it.
Why a binding layer at all
Causl's engine is framework-agnostic. A Graph exposes
read, commit, subscribe, and
subscribeCommits; everything else is layered on top. React, by
contrast, has its own contract for external stores (tear-free reads under
concurrent rendering, StrictMode double-invocation, hydration ordering). The
binding layer in @causl/react exists to satisfy both contracts
simultaneously, so that application code can write useCauslNode(total)
and not have to think about useSyncExternalStore, capability narrowing,
or commit-log subscription identity.
The package is deliberately small. There is no useGraphValue,
useGraphStatus, useGraphConflicts family of hooks: status,
conflicts, and explanations are values you select from the same graph. The public
surface is six hooks plus a provider and a Suspense-aware hydration component.
1. The provider
<CauslProvider> is the single entry point. It takes a
host-constructed Graph and, optionally, an MVU update
runner for typed dispatch. The provider deliberately does not own
the graph — lifecycle, disposal, and replacement remain with the host —
so React mounting and unmounting cannot lose engine state.
import { createCausl, input, derived } from '@causl/core';
import { CauslProvider } from '@causl/react';
const graph = createCausl();
const count = input(graph, 0);
const doubled = derived(graph, g => g.read(count) * 2);
export function App() {
return (
<CauslProvider graph={graph}>
<Counter />
</CauslProvider>
);
}
The context value is memoised on graph identity, so consumer hooks
do not re-subscribe on every render. A second provider further down the tree creates
an isolated family namespace (see section 4) even when both
providers share the same graph.
2. useCauslNode — per-node subscriptions
useCauslNode(node) is the right default for reading a single node.
It subscribes via graph.subscribe(node, cb), which the engine fires
exactly once per commit during which the node's value changed (with an
Object.is equality cutoff applied at the engine boundary). React's
onChange never fires for commits that do not touch the subscribed node
— unrelated work in the same transaction does not provoke a re-render.
import { useCauslNode, useDispatch } from '@causl/react';
function Counter() {
const value = useCauslNode(doubled);
const dispatch = useDispatch();
return (
<button onClick={() => dispatch({ kind: 'INC' })}>
Doubled: {value}
</button>
);
}
Detailed explanation — why per-node beats per-commit for hot leaves. The selector-baseduseCauslhook subscribes to every commit and deduplicates at the selector boundary withObject.is. That is a one-line subscription and an O(selector) cost per commit. For a leaf component reading a single node out of a 10 000-node graph, that is wasted work on 99.99% of commits.useCauslNodeinstead pushes the dedup down to the engine's per-node subscriber list, so the React layer is woken only when the node's value actually changed. The dropped-frames gate (≤ 5% over 30 s on a 1 000-cell viewport at 60 Hz, plus p95 commit-to-paint ≤ 16 ms) lives in the e2e suite atpackages/react/e2e/tests/dropped-frames-1000.spec.ts.
3. useCausl with selectors — multi-node projections
When a component needs to combine values from multiple nodes — a derived
aggregate that does not exist as a graph node, a tuple, a sliced array — reach
for the selector-based useCausl(selector). The selector receives a
ReadOnlyGraph (a capability-narrowed handle that cannot call
commit, input, derived, or
exportModel) and must return a value derived purely from
graph.read calls.
import { useCausl } from '@causl/react';
function Stats() {
const { total, count } = useCausl(g => ({
total: g.read(sumNode),
count: g.read(countNode),
}));
return <span>{count} items, total {total}</span>;
}
The selector runs once per commit; the return is compared to the previous return
with Object.is. If equal, the component does not re-render. The
ReadOnlyGraph proxy enforces capability narrowing at runtime — a
selector that smuggles a commit call past TypeScript via
as any throws CapabilityViolation.
The fresh-literal trap
The selector above returns a fresh object on every commit, even when
total and count are unchanged. Object.is on
two distinct object references is always false, so this component
re-renders on every commit even though the displayed values never differ. Use
useCauslShallow when the selector returns a fresh object or array
literal:
import { useCauslShallow } from '@causl/react';
const stats = useCauslShallow(g => ({
total: g.read(sumNode),
count: g.read(countNode),
}));
useCauslShallow swaps the equality predicate for a one-level shallow
compare of object keys / array elements. Two structurally equal returns are treated
as equal and the component does not re-render.
4. useCauslFamily — per-key node lifetimes
Virtualised lists, dynamic forms, and any "one node per identity" pattern need a
way to mint a node when an entity appears and dispose it when the entity leaves the
tree. useCauslFamily closes Jotai's atomFamily gap: the first
mount of a key invokes a factory; subsequent mounts return the cached handle;
refcounting tracks live consumers; the last unmount schedules disposal.
import { useCausl, useCauslFamily } from '@causl/react';
function Row({ rowId }: { rowId: string }) {
const node = useCauslFamily(`row:${rowId}`, (graph, key) =>
graph.input(key, defaultRow),
);
const value = useCausl(g => g.read(node));
return <tr><td>{value.title}</td></tr>;
}
Three properties make this hook safe in real-world apps:
- Identity sharing. Two components rendering
Row rowId="42"in the same provider observe the sameNode<Row>. The factory runs once. - StrictMode-safe disposal. When the refcount drops to zero, disposal is scheduled on a microtask. If StrictMode's mount–unmount–remount cycle re-mounts the key before the microtask fires, the refcount bumps back up and the disposal call is skipped.
- Per-provider namespace. The family registry lives on
CauslContext, not in a module-global cache. Two providers around the same graph maintain isolated namespaces, so a key collision in one provider does not leak a node into the other.
Disposal routes through @causl/core/internal's dispose
rather than a public method on Graph: lifecycle is an adapter concern,
not a load-bearing engine primitive, so it stays out of the public package's SemVer
surface.
5. useCauslSuspense — Suspense-aware reads
For asynchronous resources modelled with @causl/sync's
ResourceState<T>, useCauslSuspense projects the
tagged-union state into either a resolved T, a thrown Promise (caught
by <Suspense>), or a thrown error (caught by an error boundary):
import { useCauslSuspense } from '@causl/react';
import type { ResourceState } from '@causl/sync';
function Profile() {
const user = useCauslSuspense<User>(g => g.read(userResource));
return <h1>{user.name}</h1>;
}
function Page() {
return (
<Suspense fallback={<Spinner />}>
<ErrorBoundary><Profile /></ErrorBoundary>
</Suspense>
);
}
The state-to-Suspense mapping is:
| State | Behavior |
|---|---|
loaded | Return value. |
stale | Return cached value (do not throw — you already have something to render). |
loading | Throw the engine-anchored promise on the state. Identity-stable across renders for the same loading episode. |
errored | Throw the original error. |
idle | Throw a Promise that resolves on the next graph commit (identity-stable per graph). Suspends, not errors. |
Promise identity is what makes Suspense actually behave: SuspenseList ordering,
transition cached-value display, and hydration-warning suppression all key off
Promise identity across renders. The loading state carries its own
engine-owned Promise; the idle case is cached per-graph in a WeakMap
so multiple renders see the same Promise reference until the next commit.
6. Persisted inputs and React
When you combine @causl/persistence with the React binding, the
pattern is: construct the graph at the host level, restore the snapshot
before mounting the provider, and let React see a graph that already has
its hydrated values. Reads under the provider then see the persisted values from
the very first render — no flash of empty state.
import { createCausl } from '@causl/core';
import { createPersistedGraph } from '@causl/persistence';
import { CauslProvider } from '@causl/react';
const { graph, ready } = createPersistedGraph({
graph: createCausl(),
storage: localStorageAdapter('app-v1'),
});
await ready; // Wait for restore before mounting.
createRoot(document.getElementById('root')!).render(
<CauslProvider graph={graph}><App /></CauslProvider>,
);
If you cannot await before render (for example, when persistence is best-effort
and the app should boot from defaults if storage is slow), wrap the provider's
children in <Suspense> and let an idle
ResourceState carry the loading semantics through the same hook
machinery you use for any other async resource.
7. <Hydrate> — SSR snapshot application
For server rendering, capture a GraphSnapshot on the server and
apply it on the client before App commits anything of its own. The
Hydrate component does this via useLayoutEffect so SSR HTML
and the first client paint observe the same hydrated values, while render bodies stay
pure.
// app/page.tsx (Next.js server component)
const graph = createCausl();
bootGraphFromDb(graph);
const snapshot = graph.snapshot();
return (
<CauslProvider graph={clientGraph}>
<Hydrate snapshot={snapshot}>
<App />
</Hydrate>
</CauslProvider>
);
Hydrate keys on graph identity, not on snapshot prop identity. A
module-scoped WeakMap<Graph, GraphSnapshot> records "applied"
pairs; under StrictMode's mount–cleanup–remount cycle the second mount
finds the pair recorded and short-circuits, so subscribers see exactly one
Commit { intent: 'hydrate' } per provider mount. Snapshot-prop churn
without a graph swap is a no-op (the engine's hydrate is
non-monotonic on now, so re-hydrating on every prop change would drag
GraphTime backward).
8. StrictMode
Every hook in @causl/react is built on
useSyncExternalStore and is StrictMode-safe by design. The three places
where naïve adapter code typically misbehaves are:
- Subscription identity.
useCauslmemoises its subscribe function on[graph], so React only re-subscribes when the provider's graph handle itself changes — not on every render double-invocation. - Family disposal.
useCauslFamilydefers disposal to a microtask so a StrictMode unmount-then-remount cycle does not destroy and recreate the node; the second mount cancels the pending dispose. - Hydration.
<Hydrate>guards on a module-scoped WeakMap of (graph, snapshot) pairs, which survives the StrictMode component-instance teardown auseRefwould not.
9. The H1 hazard: useMemo on read() values
This is the single most important adopter pitfall in the React binding. Per
SPEC §15.1 / §18A.5, graph.read(node) is
not contractually required to return the same JavaScript reference
across calls — and under the real Rust engine (rust-ssot, the production default in
causl-client), this hazard is live, not hypothetical:
every read() deserialises a fresh object as the value crosses the FFI
boundary, so reference identity across commits is not guaranteed. On the
pure-TS floor the same reference happened to be reused, but that was never the
contract.
Adopters who key useMemo / React.memo / dependency
arrays off the return reference will silently re-compute every commit on the wasm
engine:
// WRONG — reference may change on every commit after WASM migration.
function Chart() {
const data = useCausl(g => g.read(seriesNode));
const transformed = useMemo(() => transform(data), [data]);
return <Plot data={transformed} />;
}
The fix is to key memoisation on a stable identity that the engine
does guarantee. SPEC §15.1 amendment names two: the
commit.time (a GraphTime exposed on the
Commit object) or the per-node version counter that
EngineTelemetry surfaces. The version counter is the cheaper option in
a tight loop — it is exposed as the public accessor
engine.stats().nodeVersion(node). Pinning a downstream cache key to that
integer gives you a stable memoisation surface that survives the wasm engine's
per-read FFI deserialisation boundary, with byte-identical behaviour across the TS
floor and the Rust engine for the same commit sequence.
// RIGHT — key memoisation on the per-node version, not the reference.
import { useCausl, useCauslNode } from '@causl/react';
function Chart() {
const data = useCauslNode(seriesNode);
// `engine.stats().nodeVersion(node)` increments by exactly 1 each
// commit where the node's value changed (per SPEC §15.1's
// `!Object.is` cutoff) and stays unchanged otherwise — a
// no-op write or sibling write does not bump the counter.
const version = useCausl(g => g.stats().nodeVersion(seriesNode));
const transformed = useMemo(() => transform(data), [version]);
return <Plot data={transformed} />;
}
The contractual identity surface is value identity at a fixed
GraphTime: Object.is(read(node)@t, read(node)@t)
holds for any two synchronous reads at the same GraphTime.
Object.is(read(node)@t, read(node)@t') across two different commit
times may or may not hold, depending on backend.
Detailed explanation — why this is a SPEC clarification, not a breaking change. An external ship-verdict review surfaced the H1 hazard from the WASM adopter audit as a load-bearing risk. The pure-TS floor reused the same reference "by accident"; adopters who wrote against that behaviour have a working component that becomes a perf regression on the Rust engine, where everyread()returns a fresh deserialised object (§18A.5). The amendment makes the always-implied contract explicit — identity across commits was never promised — and points adopters at the stable keys (commit.time, per-node version). The property testread-no-identity-contract.property.test.tsenrols aBackendEnginewrapper that returns deep-copies on everyread(); the engine itself never relies on identity, so internal code is unaffected.
10. Recommended selector patterns
A small set of patterns covers the vast majority of components.
Read one node
const value = useCauslNode(node);
Use useCauslNode — the engine-level dedup is cheaper than
any selector dedup, and the API is the smallest possible.
Read N nodes into a tuple or object
const { a, b } = useCauslShallow(g => ({
a: g.read(aNode),
b: g.read(bNode),
}));
Use useCauslShallow, never plain useCausl, when the
selector returns a fresh literal. Otherwise every commit causes a re-render.
Derive a scalar from N nodes
const ratio = useCausl(g =>
g.read(numerator) / Math.max(1, g.read(denominator)),
);
Plain useCausl is correct because Object.is on two
equal numbers is true.
Read a value that crosses the FFI boundary
const data = useCauslNode(bigArrayNode);
const version = useCausl(g => g.stats().nodeVersion(bigArrayNode));
const sorted = useMemo(() => data.slice().sort(), [version]);
Key the useMemo on the per-node version, not the value reference.
See section 9.
Read bulk numeric data
import { useCauslTypedArrayNode } from '@causl/react';
const view = useCauslTypedArrayNode(weightsNode, Float64Array);
Returns a typed-array view stable across renders until the next commit. Designed to become zero-copy once the WASM backend lands; falls back to a coerced view of the JS-engine value with the same stability contract, so adopters can program against the final shape today.
Where to go next
- The MVU update pattern — typed
messages,
defineMsgs,createUpdate, anduseDispatch. - Async resources with @causl/sync — modelling fetches, retries, and races as graph nodes.
- Persistence and SSR — storage adapters, snapshot transport, and hydration patterns.
- @causl/react API reference — full TypeDoc.