@lazily-hub/lazily-react
v0.3.0
Published
React/Preact bindings for @lazily-hub/lazily-js: drive React state from lazily reactive Source/Computed handles via useSyncExternalStore. Glitch-free, equality-guarded re-renders.
Maintainers
Readme
@lazily-hub/lazily-react
React / Preact bindings for
@lazily-hub/lazily-js — drive React
state from the lazily Cell kernel (Source / Computed) via
useSyncExternalStore. Glitch-free, equality-guarded re-renders.
The whole binding is a thin adapter: a lazily effect reads the handle
(registering the dependency edge) and calls React's re-render callback on
invalidation; useSyncExternalStore reads the cached value. lazily's pull-based
glitch-free computeds and its deep-equality guards carry straight through to React.
Install
npm install @lazily-hub/lazily-react @lazily-hub/lazily-js reactPreact instead of React: alias react → preact/compat (the standard
convention). useSyncExternalStore is exported by both react (≥18) and
preact/compat (≥10.16), so no code changes are needed.
// vite.config.js / bundler alias
{ resolve: { alias: { react: "preact/compat" } } }Usage
import { createContext } from "@lazily-hub/lazily-js/reactive";
import { LazilyProvider, useSource, useComputed } from "@lazily-hub/lazily-react";
const ctx = createContext();
function Counter() {
const [count, setCount] = useSource(0);
const doubled = useComputed(() => count * 2, [count]);
return (
<button onClick={() => setCount((c) => c + 1)}>
{count} (doubled: {doubled})
</button>
);
}
export function App() {
return <LazilyProvider context={ctx}><Counter /></LazilyProvider>;
}Hooks
| hook | Cell kernel construction | lazy? | equality guard? |
|-------------------|--------------------------|-------|-----------------|
| useSource(initial)| ctx.source (Source) | src | yes (on write) |
| useComputed(fn, deps) | ctx.computed (Computed) | yes | yes |
| useLazily(handle)| any (read-only) | — | from handle |
| useLatestDurableSnapshot(projection) | latest-durable snapshot reader | yes | yes |
| useLatestDurableEntry(projection, key) | one latest-durable key reader | yes | yes |
| useLatestDurableGeneration(projection) | actor generation fence reader | yes | yes |
There is intentionally no useSlot and no useSignal:
useSlotis deleted (Cell kernel, #lzcellkernel — design §9.4 step 6, zero call sites). Under the Cell kernel v2 every computed is guarded — there is no unguarded derived construction (slotsurvives in lazily-js only as a deprecated alias of the guardedcomputed). Equal recomputes do not propagate, so the one derived hook is the guardeduseComputed.useSignalnever existed. The eager construction is nowctx.computed(f).eager(), and a React binding gains nothing from making it eager — React only renders on invalidation, andgetSnapshotreads the (lazily-recomputed-on-read) computed, so it always sees the fresh value with no stale-frame risk.useLazilyreads externally-createdSourceandComputedhandles without creating an eager wrapper.useSource— component-local mutable source (aSourceviactx.source), returns[value, setValue]likeuseState.setValueaccepts a value or(prev) => next. The source is disposed on real unmount.useComputed— the default (and only) derived hook: a guardedComputed. Equal recomputes are suppressed at the lazily level (the subscribe effect never runs), so React never re-renders on a no-op recompute.
The equality guard at the React level
useSyncExternalStore uses Object.is on the snapshot. With a primitive
recompute that comes back equal, useComputed skips the re-render for free. The
guard earns its keep when the compute returns a fresh object each invalidation:
useComputed(() => ({ n: a % 2 })): lazily's deep-equal guard suppresses propagation → no re-render, even though the reference changed.
(See test/hooks.test.js.)
Sharing handles across components
useSource creates a component-local Source. To share state, create the source
externally and read it with useLazily; write it via ctx.set:
const shared = ctx.source(0);
// in any component: const v = useLazily(shared);
// anywhere: ctx.set(shared, v + 1);Latest durable projection
The latest-durable-projection entry point adapts lazily-js's
LatestDurableProjection to React. The snapshot, keyed-entry, and generation
hooks subscribe to the projection's memoized readers, so a component observes
the latest desired value, in-flight claim, monotone durable frontier, and
reconnect generation without owning the state machine. Commands remain on the
projection itself and therefore preserve its per-key single-flight ordering.
const projection = new LatestDurableProjection(ctx, 1);
function SaveStatus({ documentId }) {
const state = useLatestDurableEntry(projection, documentId);
return <span>{state?.inflight ? "saving" : state?.desired ? "pending" : "saved"}</span>;
}The integration suite replays lazily-spec v0.38.0's canonical
egress/latest_durable_projection.json fixture through a mounted React
subscriber. The state machine corresponds to
LazilyFormal.LatestDurableProjectionCore in lazily-formal v0.38.1.
How it works
React component
└─ useSyncExternalStore(subscribe, getSnapshot)
├─ subscribe = lazily effect that reads the handle (registers edge) + onChange
└─ getSnapshot = ctx.get(handle)- lazily effects flush synchronously before
set/batchreturns, matching the notify-then-read contractuseSyncExternalStoreexpects. - Snapshot stability (required to avoid React's "getSnapshot should be cached" loop) comes for free: lazily caches node values and only changes the reference on a real change.
- The bridge skips the initial forced effect run's
onChange(subscribe should notify only future changes); the first run still registers the dependency edge.
The framework-agnostic adapter lives in src/bridge.js (readHandle,
createLazilySubscription) and is unit-tested without React in
test/bridge.test.js.
Node lifetime
useSource disposes its Source and useComputed disposes its Computed on
real unmount and on deps-change via each handle's canonical dispose() method.
Disposal is strict-mode-safe: it is deferred
one microtask and cancelled if React 18 dev's simulated remount
(setup → cleanup → setup) re-subscribes the same handle, so the dev double-invoke
never frees a handle that the second setup still uses. useLazily is read-only
and does NOT dispose its externally-owned handle (the caller manages its lifetime).
Develop
make check # build (node --check) + node:test
npm test # node --test test/*.test.jsThe lazily family
lazily is one reactive kernel — Source / Computed / Effect, keyed
collections, state charts, CRDTs, and a distributed plane — implemented natively
in each language and held to a single cross-language contract:
lazily-spec— the wire protocol, the generated feature matrix, and the conformance corpus every binding replays.lazily-formal— the Lean 4 formal model the bindings share.
lazily-react is not one of the language bindings. It is a thin React /
Preact adapter layered over the JavaScript binding: @lazily-hub/lazily-react
owns only the hooks (useSource, useComputed, useLazily, LazilyProvider)
and the useSyncExternalStore bridge, while every reactive primitive, CRDT, and
wire type comes from
@lazily-hub/lazily-js. Conformance
against lazily-spec is lazily-js's job, not this package's.
| repo | language |
|---|---|
| lazily-rs | Rust — the reference implementation |
| lazily-py | Python |
| lazily-go | Go |
| lazily-kt | Kotlin / JVM |
| lazily-js | JavaScript / TypeScript — the package this one layers over |
| lazily-cs | C# / .NET |
| lazily-cpp | C++ |
| lazily-zig | Zig |
| lazily-dart | Dart / Flutter |
| lazily-react | React / Preact over lazily-js — you are here |
Per-binding feature parity is tracked in the coverage.json-generated matrix in
lazily-spec; read it there rather
than any hand copy.
License
Licensed under the Apache License, Version 2.0. See LICENSE and NOTICE.
