Public entry point for @causl/react, the React binding layer for the Causl dependency engine. This module re-exports the provider, hooks, MVU update helpers, and supporting types that together form the application surface for React hosts.

The barrel is deliberately narrow. tx.set(node, value) is a write API, not a thinking API: application developers do not think "I will mutate cell:wb1:Sheet1:A1," they think "the user clicked Save." The MVU shape — typed Msg discriminated unions plus an update runner — is the front door that lets Msg carry the "make impossible states impossible" guarantee at the application surface rather than scattering it across 57 internal enum tags. The previous draft's nine kinds of hook (useGraphValue, useGraphStatus, useGraphConflicts, useGraphExplanation, useGraphTransaction, useGraphDerived, useGraphSelector, useGraphResource) collapse to two: useCausl for selector-driven subscription and useDispatch for typed dispatch. Status, conflicts, and explanations are values selectable from the same store and do not need their own hooks.

Public surface:

  • CauslProvider — provides a Graph through React context
  • useCausl(selector) — re-renders when the selected value changes
  • useDispatch() — returns a typed Msg dispatcher
  • createUpdate<Msg, Graph> — typed Update<Msg, Graph> runner factory
  • defineMsgs / MsgOf / Msg — typed Msg discriminated-union helper (#369)
  • assertNever — exhaustiveness probe for switch (msg.kind)

@causl/react

React 18+ bindings for @causl/causl-wasm-ts.

These bindings drive the engine backing the provider's Graph, and as of @causl/causl-wasm-ts 0.5.0 there is exactly one (causl/causl-wasm-ts#280). The graph you hand to <CauslProvider> is built by createCausl(): the default factory, exported from @causl/causl-wasm-ts. It routes synchronously to the real wasm engine once @causl/causl-wasm-ts/wasm has been preloaded for the default bridge (via preloadCauslWasm()); with nothing preloaded it throws during construction. So await preloadCauslWasm() once at app init: and once in your test setup, in a global setup file rather than per test: is a hard prerequisite for every example below. The wasm engine is also reachable directly through the @causl/causl-wasm-ts/wasm subpath as createCauslWasm() (async) / createCauslWasmSync() (sync), kept out of the main bundle. The hooks below only ever touch the Graph interface.

The pure-TypeScript floor engine is not in this package: epic #31 / #34 un-exported its factory from the public @causl/causl-wasm-ts barrel and from all four subpaths, and causl/causl-wasm-ts#279 then deleted the declaration. Adopters cannot select that engine in causl-client, and since 0.5.0 nothing degraded onto it either: the §18A.13.1 capability fallback is withdrawn, because the floor and rust-ssot answer differently on a §18A.1.1 MUST-be-identical surface (causl/causl-wasm-ts#272). (The dual-engine TS floor lives in causl/causl-core-ts: its differential oracle and benchmark repo: which is now the organisation's only owner of that floor.)

The wasm engine is synchronous from a consumer's point of view. The one unavoidable async (compiling the WebAssembly.Module) is split out of construction so React render code never has to await:

  • await preloadCauslWasm(opts?): call once at app/init. It compiles and caches the WebAssembly.Module (plus the _bg.js sidecar and the compute-imports snippet), keyed by bridge; it is idempotent (concurrent calls share one compile, a transient failure drops the cache). Companions isCauslWasmPreloaded(bridge?) and getPreloadedCauslWasm(bridge?) are synchronous peeks.
  • createCauslWasmSync(handle?, create?): Graph: fully synchronous (a new WebAssembly.Instance from the cached module, zero await). With nothing preloaded it throws CauslWasmNotPreloadedError (code: 'CAUSL_WASM_NOT_PRELOADED'). The { fallbackToTs: true } / { fallbackToJs: true } opt-out of that throw was removed in 0.5.0: catch it and decide what an unsupported host should do.
  • createCauslWasm(opts?): Promise<Graph>: retained, now just preloadCauslWasmcreateCauslWasmSync over one codepath.

So a synchronous React tree (hooks, render, xldatagrid) can build a wasm graph at the call site with no await: the single await lives at app init:

import { preloadCauslWasm, createCauslWasmSync } from '@causl/causl-wasm-ts/wasm'

// once, at app init (e.g. before hydration / render):
await preloadCauslWasm()

// thereafter, anywhere — synchronous, no await:
const graph = createCauslWasmSync()

Node's --target nodejs glue is already synchronous end-to-end, so the server needs no preload; the browser bundler target needs the one-time preload above. All three factories build engines exposing the same Graph, so adopt the wasm path for byte-identity and forward-compatibility: perf is explicitly treated as immaterial here.

Wasm-only core (SPEC §18A.13, and §18A.13.1 withdrawn at 0.5.0). causl-client ships wasm as its sole engine: createCausl routes to the wasm engine or throws, and the pure-TypeScript floor engine is un-exported from causl-client's public surface. Executed wire-before-cut (epic #31: #32 WIRE → #33 FLIP → #34 CUT); the §18A.3 FFI lift has since landed (causl/causl-core-rs#170), so every adopter op resolves from Rust. §18A.13.1 (2026-06-23) briefly retained that engine as the implicit path's WasmGC-unavailable capability fallback; that reversal is withdrawn at 0.5.0 (causl/causl-wasm-ts#280) because the fallback and the primary disagree (causl/causl-wasm-ts#272). onCauslCapabilityFallback stays exported, @deprecated and never firing until 0.6.0. The accepted cost is a dropped host tier (hosts below the declared engine floor, stated once on WASM_HOST_FLOOR in packages/core/src/wasm-registry.ts, causl/causl-wasm-ts#426) with no flag that restores the old behaviour. The literal zero-TS core (deleting the engine declaration) has landed under EPIC causl/causl-wasm-ts#275, sub-task causl/causl-wasm-ts#279. The driver is complexity-elimination; perf is explicitly immaterial (never a gate). (causl/causl-core-ts keeps the dual-engine TS floor as its differential oracle and benchmark repo.)

See the Node.js integration guide for the producer/consumer split and the read()-identity migration. (That guide lives in the repository, not in the published package, so the link is absolute.)

pnpm add @causl/react @causl/causl-wasm-ts react react-dom
import { preloadCauslWasm } from '@causl/causl-wasm-ts/wasm'
import { createCausl } from '@causl/causl-wasm-ts'
import {
CauslProvider,
createUpdate,
defineMsgs,
payload,
useCausl,
useDispatch,
type MsgOf,
} from '@causl/react'

// Required since 0.5.0, before the first createCausl(). At a module top
// level this is a top-level await; in an app, hoist it into your entry
// point (and into a global test-setup file) and render after it resolves.
await preloadCauslWasm()

const graph = createCausl()
const counter = graph.input('counter', 0)

// Declare the discriminated `Msg` union once, as a record of
// `tag → payload?`. The same shape pairs with `createUpdate`'s
// record-of-handlers below.
const msg = defineMsgs({
inc: null,
set: payload<{ value: number }>(),
})
type Msg = MsgOf<typeof msg>

const update = createUpdate<Msg>({
inc: (_m, g) => {
g.commit('inc', (tx) => tx.set(counter, g.read(counter) + 1))
},
set: (m, g) => {
g.commit('set', (tx) => tx.set(counter, m.value))
},
})

function Counter() {
const value = useCausl((g) => g.read(counter))
const dispatch = useDispatch<Msg>()
return <button onClick={() => dispatch(msg.inc())}>{value}</button>
}

export const App = () => (
<CauslProvider graph={graph} update={update}>
<Counter />
</CauslProvider>
)

The §8 MVU surface is the application boundary where "make impossible states impossible" applies: messages are the front door, and the Msg discriminated union is what the type system enforces.

  • defineMsgs({ tag: null | payload<T>(), ... }): record-of- payloads builder returning a typed variant-constructor record. The same record shape pairs with createUpdate's record-of-handlers, so tags are declared once.
  • MsgOf<typeof builder>: extractor pulling the closed Msg union back out of a builder for use as the type parameter to createUpdate<Msg>() or useDispatch<Msg>().
  • Msg<K, P>: generic variant template for callers who prefer to spell the union out by hand: Msg<'inc'> | Msg<'set', { value: number }>.
  • assertNever(value): exhaustiveness probe for the default arm of a switch (msg.kind). Adding a tag without a matching arm is a compile error at the call site, not a runtime throw.

Adding a fourth tag to the defineMsgs record without adding a matching handler is a compile error at the createUpdate call site; adding a fourth tag without naming it in a switch (msg.kind) is a compile error at the assertNever(msg) default arm. Both gates fail closed.

  • useCausl(selector): (graph) => T; re-renders on commits whose selector return is not Object.is-equal to the previous. Subscribes to every commit via subscribeCommits and deduplicates at the selector boundary.
  • useCauslShallow(selector): same but with a shallow comparison. Use this for object/array selectors that would otherwise return fresh references each call.
  • useCauslNode(node): per-node subscription hook (#677). Routes through graph.subscribe(node, cb) so React's onChange only fires for commits that change this node: unrelated commits never trigger a re-render. Prefer this over useCausl when reading a single node; prefer useCausl(selector) for multi-node projections. The e2e dropped-frames gate (≤ 5% over 30s on a 1000-cell viewport at 60Hz, p95 commit occupancy ≤ 6ms) shipped in #765. The second cell used to be "p95 commit-to-paint ≤ 16ms"; #489 retired it because it measured the display's frame interval rather than the commit.
  • useCauslTypedArrayNode(node, ctor): typed-array projection hook (#688, sub-task of WASM substrate epic #680, shipped in #1055). Returns a Float64Array | Uint8Array | Int32Array view that is stable across renders until the next commit changes the node. See the Current state callout below.
  • useCauslFamily(factory): atomFamily-style per-key node identity within a provider, with refcount-driven disposal.
  • useCauslSuspense(resource, selector): projects a SuspendableResource<T> through a selector and either returns the resolved T, throws a Promise for <Suspense>, or throws an error for an error boundary.
  • useDispatch<Msg>(): typed dispatcher running the provider's update against the provider's graph.
  • <Hydrate snapshot={...} />: SSR hydration component (#130); applies a server-captured GraphSnapshot to the provider's graph on first mount.

useCauslTypedArrayNode ships the value-copy path today: it reads the node's value across the FFI boundary and coerces it into the requested typed array. The full zero-copy view-into-linear-memory implementation (a WebAssembly.Memory.buffer-backed view) is a follow-up adapter task; the call site is forward-compatible and keeps the same signature when it lands.

The hook:

  • detects the WASM backend via loadWasmBackend() (#1031), once per process, started by the first component that mounts the hook; the loader surfaces WasmBackendUnavailableError (code: 'CAUSL_WASM_NOT_BUILT') on an unbuilt artefact,
  • reads the node's current committed value from the Rust engine (a live reference to the committed value, not a copy: see SPEC §15.1 below),
  • coerces non-matching values via ctor.from(value) (one-shot copy on commit), and
  • caches the view reference per commit so React.memo-style identity skips work.

Importing @causl/react performs no artefact fetch, no compile and no instantiate (#488). The probe reaches the opt-in @causl/causl-wasm-ts/wasm subpath through a dynamic import, which keeps a bundler from folding the subpath into the main chunk, and it is issued from the hook's useSyncExternalStore subscribe callback, which keeps the runtime from loading it in an app that never renders the hook. The two are separate properties; until #488 the first was mistaken for the second and an unconditional module-scope call fetched the artefact in every app that imported the package, including on the server.

The first render that reaches the hook sees the in-flight answer and takes the JS fallback for that render; when the probe lands it wakes its watchers, so React re-reads the snapshot. If you would rather pay the probe at startup, call it yourself:

import { warmCauslTypedArrayBackend } from '@causl/react'

// app entry, alongside `await preloadCauslWasm()`
void warmCauslTypedArrayBackend()

It is idempotent, it never rejects, and nothing in @causl/react calls it for you.

PR #1129 amended SPEC §15.1 to make explicit that graph.read(node) does not contractually guarantee reference identity across commits for object-shaped values. That is a statement about what you may depend on, and it still stands: do not key memoisation on the read return reference.

What the shipping rust-ssot engine actually does is the opposite hazard, and in React it is the one that bites:

read(node) returns your own live, unfrozen object. Two reads in the same tick are ===. A read is === the value you passed to input / tx.set. It survives unrelated commits unchanged. Object.isFrozen on it is false. So graph.read(node).count = 9 mutates committed state: with no commit, no GraphTime advance, and no subscriber fire. Every later read sees it; nothing re-renders.

Practical consequences in React:

  • Never mutate a value a selector returned. useCausl((g) => g.read(node)) gives you the engine's own object. Reducers, sort helpers, form state and Array.prototype.sort / .reverse on a read array all edit committed state in place, invisibly. Clone first (structuredClone, a spread) and commit the clone.
  • Because the reference is stable today, a plain useCausl((g) => g.read(node)) does not re-render on every commit. Do not add useCauslShallow to defend against churn that is not happening: but do not rely on the stability either: it is not contractual. useCauslNode(node) remains the right hook for a single node.
  • Selectors that project primitives out of an object read ((g) => g.read(node).count) are unaffected, and are the cheapest way to stay out of the whole question.

When you need a cache key that is correct whatever the engine does with references, use the per-node version counter: graph.stats().nodeVersion(node) is an integer that advances by exactly 1 on each commit in which the node's value changed (the SPEC §15.1 !Object.is cutoff) and is unchanged on every other commit: a no-op write, an empty commit, or a sibling write that did not touch this node.

stats() is engine authority, so it is deliberately not on the ReadOnlyGraph a selector receives: reaching for g.stats() inside a selector throws CapabilityViolation at render. Close over the same graph you passed to <CauslProvider> instead:

import { useMemo } from 'react'
import { useCausl } from '@causl/react'
import type { Node } from '@causl/causl-wasm-ts'

// `graph` is the module-scope graph handed to <CauslProvider> above.

function ExpensiveProjection({ node }: { node: Node<MyValue> }) {
// NOT `useCausl((g) => g.stats()…)` — `g` is a narrowed ReadOnlyGraph
// (read / subscribe / subscribeCommits / now) and `stats` is not on
// it. The selector still re-runs on every commit, so the version is
// always current.
const version = useCausl(() => graph.stats().nodeVersion(node))
const value = useCausl((g) => g.read(node))
// Recomputes only when `version` increments — i.e. only when this
// node's value actually changed. An unrelated commit leaves
// `version` alone and the memoised projection is reused.
return useMemo(() => projectExpensively(value), [version])
}

The counter is cross-backend byte-identical (the production Rust engine vs. the internal TS conformance reference) for the same commit sequence, so the same cache behaviour holds across both without code changes. See SPEC §15.1 and PR #1245 for the underlying contract.

All hooks are built on useSyncExternalStore. StrictMode double-mount, concurrent rendering, and act()-wrapped updates are covered by test/strictMode.test.tsx; Hydrate uses a WeakMap-by-graph guard so the second mount in StrictMode's mount/cleanup/remount cycle does not double-apply the snapshot. The CI matrix runs the package against both React 18.3 and React 19 (see .gitea/workflows and peerDependencies in package.json).

Interfaces

CauslContextValue
CauslProviderProps
HydrateProps
PayloadMarker

Type Aliases

CauslTypedArray
CauslTypedArrayCtor
Dispatch
FamilyFactory
FamilyGraph
Msg
MsgBuilder
MsgOf
MsgSpec
Selector
SuspendableResource
Update

Variables

CauslContext
VERSION

Functions

assertNever
CauslProvider
createUpdate
defineMsgs
Hydrate
payload
runMessages
shallowEqual
useCausl
useCauslFamily
useCauslNode
useCauslShallow
useCauslSuspense
useCauslTypedArrayNode
useDispatch
warmCauslTypedArrayBackend