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-based useCausl hook subscribes to every commit and deduplicates at the selector boundary with Object.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. useCauslNode instead 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 at packages/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:

  1. Identity sharing. Two components rendering Row rowId="42" in the same provider observe the same Node<Row>. The factory runs once.
  2. 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.
  3. 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:

StateBehavior
loadedReturn value.
staleReturn cached value (do not throw — you already have something to render).
loadingThrow the engine-anchored promise on the state. Identity-stable across renders for the same loading episode.
erroredThrow the original error.
idleThrow 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:

  1. Subscription identity. useCausl memoises its subscribe function on [graph], so React only re-subscribes when the provider's graph handle itself changes — not on every render double-invocation.
  2. Family disposal. useCauslFamily defers 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.
  3. Hydration. <Hydrate> guards on a module-scoped WeakMap of (graph, snapshot) pairs, which survives the StrictMode component-instance teardown a useRef would 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 every read() 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 test read-no-identity-contract.property.test.ts enrols a BackendEngine wrapper that returns deep-copies on every read(); 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