React Integration
April 25, 2026 · View on GitHub
Status: Non-Goal for This Library
React integration is out of scope for @zeix/cause-effect and will not be added to this repository. The reasons:
Different rendering models. React renders through a reconciler and virtual DOM; re-renders are React's mechanism for propagating state changes to the UI. Cause & Effect propagates changes through a fine-grained signal graph and drives updates directly, without diffing. The two models solve the same problem differently, and bridging them adds complexity without benefiting either.
Bundle coupling. The core library targets any JavaScript environment — browsers, Bun, Node, Deno — and must stay runtime-agnostic. React integration means React becomes a dependency.
DevTools. A production-ready React integration requires React DevTools-compatible instrumentation: named signals, a dependency graph inspector, and integration with the React DevTools component tree. This is a non-trivial ongoing maintenance commitment that belongs in a dedicated package, maintained by people who have a stake in it.
When It Might Make Sense
Building a React integration is worth the effort if:
- You maintain a design system built on Cause & Effect and need React bindings alongside Web Component bindings, sharing the same signal graph across both.
- Your codebase mixes Le Truc Web Components and React components — for example, a server-rendered shell with Le Truc handling most interactivity, but a few isolated React client components that need to read from the same signal state.
- You are building tooling or a framework layer on top of Cause & Effect and React is one of the target rendering environments.
If your use case is a standard React application, Jotai, Zustand, or TanStack Query are simpler choices — they are purpose-built for React's rendering model, have mature DevTools support, and large ecosystems. See the GUIDE for a comparison.
What a React Integration Requires
If you choose to build one, here is what a minimal but complete integration needs.
Core hook: useSyncExternalStore
React 18's useSyncExternalStore is the correct primitive for subscribing React components to any external reactive store. It prevents value tearing in concurrent mode and is the only safe way to read external state during render.
import { useSyncExternalStore } from 'react'
import { createScope, createEffect, untrack } from '@zeix/cause-effect'
import type { ReadableSignal } from '@zeix/cause-effect'
function useSignal<T extends {}>(signal: ReadableSignal<T>): T {
return useSyncExternalStore(
(notify) => {
let first = true
return createScope(() =>
createEffect(() => {
signal.get() // establishes dependency
if (first) { first = false; return }
notify() // tells React to re-read snapshot
})
)
},
() => untrack(() => signal.get()), // render-time read — no graph edges
)
}
Two non-obvious requirements:
First-run guard. createEffect runs immediately to establish dependencies. On that first run, notify must not be called — React already called getSnapshot during render. Calling notify here causes a spurious extra render on mount.
untrack in getSnapshot. React calls getSnapshot during render. Reading a signal at that point must not create reactive graph edges — that is React's job. Without untrack(), you risk polluting whatever activeSink happens to be active at render time.
Lifecycle: scopes tied to components
Signals created inside a component should be disposed when the component unmounts. The right place is useEffect, not useMemo — useMemo does not guarantee cleanup on unmount:
import { useEffect } from 'react'
import { createScope } from '@zeix/cause-effect'
import type { Cleanup } from '@zeix/cause-effect'
function useScope(setup: () => void): void {
useEffect(() => createScope(setup) as Cleanup, [])
}
React StrictMode mounts and unmounts components twice in development. Because createScope returns a dispose function and useEffect cleanup calls it, the double-mount cycle works correctly without any special handling.
Async: Task states in React
Two variants cover different needs.
Explicit state — the component handles all branches itself:
import { useSyncExternalStore } from 'react'
import type { Task } from '@zeix/cause-effect'
function useTask<T extends {}>(task: Task<T>): {
data: T | undefined
isPending: boolean
error: unknown
}
Suspense variant — throws a Promise when the Task is in the nil state (no value yet), throws an Error in the err state, returns T when resolved. For the stale state (re-fetching with a retained value), it should return the retained value rather than throwing — this matches the stale fallback in match() and aligns with React's useDeferredValue mental model:
function useTaskSuspense<T extends {}>(task: Task<T>): T
// throws Promise when nil (→ <Suspense> boundary)
// throws Error when err (→ <ErrorBoundary>)
// returns retained T when stale (→ render continues with previous value)
// returns T when ok
Props: converting React props to signals
React props are values passed on every render, not signals. To make a prop reactive within the graph, convert it to a stable State signal updated synchronously before paint:
import { useLayoutEffect, useMemo } from 'react'
import { createState } from '@zeix/cause-effect'
import type { State } from '@zeix/cause-effect'
function useSignalProp<T extends {}>(value: T): State<T> {
const signal = useMemo(() => createState(value), [])
useLayoutEffect(() => { signal.set(value) }, [value])
return signal
}
useLayoutEffect rather than useEffect ensures the signal updates before the browser paints — the same timing React uses for DOM mutations. useEffect would leave a frame where the signal holds a stale value.
Collections: keyed list rendering
List's stable-key design maps directly onto React's key-based reconciliation. An <Each> component subscribes to the list's structural state (keys) and gives each child a stable item signal — only the child for a changed item re-renders:
import { useSignal } from './use-signal'
import type { List, State } from '@zeix/cause-effect'
import type { ReactNode } from 'react'
function Each<T extends {}>({
list,
children,
}: {
list: List<T>
children: (signal: State<T>, key: string) => ReactNode
}) {
const keys = useSignal(/* subscribe to list structural changes */)
return <>{keys.map(key => children(list.byKey(key), key))}</>
}
Usage:
<Each list={todos}>
{(signal, key) => <TodoItem key={key} signal={signal} />}
</Each>
TodoItem calls useSignal(signal) internally. Sorting or structural changes re-render Each; individual value changes re-render only the affected TodoItem.
match() in render
match() requires an active owner — it must be called inside an effect or scope. React render has no active owner. The integration needs a useMatch() hook that subscribes to the relevant signal states and dispatches to handlers synchronously in render:
import type { Task, Memo, MatchHandlers } from '@zeix/cause-effect'
function useMatch<T extends {}>(
signal: Task<T> | Memo<T>,
handlers: MatchHandlers<T>,
): ReturnType<MatchHandlers<T>[keyof MatchHandlers<T>]>
The cleanup semantics from match() — cleanup runs before the next handler dispatch — do not map to React render returns. Any cleanup from handlers should be managed in a wrapping useEffect, not inside useMatch itself.
SSR
useSyncExternalStore accepts a third getServerSnapshot argument. For synchronous signals (State, Memo), getServerSnapshot is identical to getSnapshot — the value is available synchronously:
useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
For Task, the server snapshot should return the nil/pending state, or a pre-fetched value if one was provided via options.value. React's streaming SSR handles async resolution through Suspense boundaries.
Package Shape
A complete integration belongs in a separate repository and package:
{
"name": "@your-org/cause-effect-react",
"peerDependencies": {
"react": ">=18",
"@zeix/cause-effect": ">=1"
}
}
Recommended exports:
| Export | Purpose |
|---|---|
useSignal(signal) | Subscribe a component to any readable signal |
useScope(fn) | Tie a Cause & Effect scope to component lifecycle |
useTask(task) | Explicit { data, isPending, error } tuple |
useTaskSuspense(task) | Suspense-throwing variant |
useMatch(signal, handlers) | Conditional dispatch in render |
useSignalProp(value) | Convert a React prop to a stable State signal |
<Each list={...}> | Keyed list rendering with item-level granularity |
The DevTools Problem
This is the single largest investment required before a React integration can be considered production-ready, and the primary reason none ships from this repository.
React developers expect to inspect reactive state in the browser's React DevTools panel. Without instrumentation, signals are invisible — you see only the component and whatever values it exposes through useState wrappers. A complete DevTools story requires:
- Named signals. A
nameoption on each factory, or inference from variable names via a Babel/SWC/TypeScript transform at build time. - Dependency graph inspection. A way to read which signals a component subscribes to and what their current values are.
- React DevTools integration. Hooking into
__REACT_DEVTOOLS_GLOBAL_HOOK__to expose signal state alongside component props and state in the component tree. - Ongoing maintenance. React DevTools internals change across React versions. This is a living commitment, not a one-time implementation.
Without DevTools, a React integration is viable for prototyping but difficult to recommend for production use on teams that depend on DevTools for debugging. Factor this cost into any build-vs-adopt decision.