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.)
await at the render site (SPEC §18A.12)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
preloadCauslWasm ∘ createCauslWasmSync 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-clientships wasm as its sole engine:createCauslroutes to the wasm engine or throws, and the pure-TypeScript floor engine is un-exported fromcausl-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).onCauslCapabilityFallbackstays exported,@deprecatedand never firing until 0.6.0. The accepted cost is a dropped host tier (hosts below the declared engine floor, stated once onWASM_HOST_FLOORinpackages/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 EPICcausl/causl-wasm-ts#275, sub-taskcausl/causl-wasm-ts#279. The driver is complexity-elimination; perf is explicitly immaterial (never a gate). (causl/causl-core-tskeeps 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>
)
Msg helperThe §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:
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,ctor.from(value) (one-shot copy on
commit), andReact.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 toinput/tx.set. It survives unrelated commits unchanged.Object.isFrozenon it isfalse. Sograph.read(node).count = 9mutates committed state: with no commit, noGraphTimeadvance, and no subscriber fire. Every later read sees it; nothing re-renders.
Practical consequences in React:
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.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.(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).
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 mutatecell:wb1:Sheet1:A1," they think "the user clicked Save." The MVU shape — typedMsgdiscriminated unions plus anupdaterunner — is the front door that letsMsgcarry 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: