@stateloom/react
React 18+ adapter for StateLoom — hooks bridging reactive signals, stores, atoms, and proxies to React components.
Install
pnpm add @stateloom/react @stateloom/corenpm install @stateloom/react @stateloom/coreyarn add @stateloom/react @stateloom/coreSize: ~1.1 KB gzipped (core entry), paradigm sub-paths add ~0.5-1 KB each
Requires: React 18+ (for useSyncExternalStore)
Optional Paradigm Packages
Add paradigm packages as needed -- they are optional peer dependencies:
@stateloom/storeforuseStore(via@stateloom/react/store)@stateloom/atomforuseAtom/useAtomValue/useSetAtom(via@stateloom/react/atom)@stateloom/proxyforuseSnapshot(via@stateloom/react/proxy)
Overview
The React adapter uses useSyncExternalStore for concurrent rendering compatibility. Each paradigm has its own sub-path import to avoid bundling unused peer dependencies.
Quick Start
import { signal, computed } from '@stateloom/core';
import { useSignal } from '@stateloom/react';
const count = signal(0);
const doubled = computed(() => count.get() * 2);
function Counter() {
const value = useSignal(count);
const double = useSignal(doubled);
return (
<button onClick={() => count.set(value + 1)}>
{value} x2 = {double}
</button>
);
}Guide
Bridging Signals to React
useSignal takes any Subscribable<T> and returns its current value. The component re-renders when the value changes:
import { signal, computed } from '@stateloom/core';
import { useSignal } from '@stateloom/react';
const name = signal('Alice');
const greeting = computed(() => `Hello, ${name.get()}!`);
function Greeting() {
const value = useSignal(greeting); // "Hello, Alice!"
return <span>{value}</span>;
}
// Later: name.set('Bob') causes re-render with "Hello, Bob!"Using Stores with Selectors
Import useStore from the /store sub-path. Pass a selector to re-render only when the selected slice changes:
import { createStore } from '@stateloom/store';
import { useStore } from '@stateloom/react/store';
const store = createStore((set) => ({
count: 0,
name: 'Alice',
increment: () => set((s) => ({ count: s.count + 1 })),
}));
function Counter() {
// Re-renders only when count changes, not when name changes
const count = useStore(store, (s) => s.count);
return <span>{count}</span>;
}TIP
Always use a selector when you only need part of the state. Without a selector, the component re-renders on any state change.
Custom Equality
Pass a third argument to useStore for custom equality:
const items = useStore(
store,
(s) => s.items,
(a, b) => a.length === b.length, // only re-render when length changes
);Atom Hooks
Import atom hooks from the /atom sub-path:
import { atom, derived } from '@stateloom/atom';
import { useAtom, useAtomValue, useSetAtom } from '@stateloom/react/atom';
const countAtom = atom(0);
const doubledAtom = derived((get) => get(countAtom) * 2);
function Counter() {
const [count, setCount] = useAtom(countAtom); // read + write
const doubled = useAtomValue(doubledAtom); // read-only
return <button onClick={() => setCount(count + 1)}>{doubled}</button>;
}
function ResetButton() {
const setCount = useSetAtom(countAtom); // write-only (no re-render)
return <button onClick={() => setCount(0)}>Reset</button>;
}useAtom— returns[value, setter]tuple (likeuseState)useAtomValue— read-only subscription (works with derived atoms)useSetAtom— write-only (component does not re-render on value changes)
Proxy Snapshots
Import useSnapshot from the /proxy sub-path for mutable-style state:
import { observable } from '@stateloom/proxy';
import { useSnapshot } from '@stateloom/react/proxy';
const state = observable({ count: 0, user: { name: 'Alice' } });
function Display() {
const snap = useSnapshot(state);
return (
<div>
{snap.user.name}: {snap.count}
</div>
);
}
// Mutate directly — components re-render automatically
state.count++;
state.user.name = 'Bob';Snapshots are deeply frozen and use structural sharing for efficient re-renders.
SSR Scope Isolation
Wrap your component tree with ScopeProvider to isolate state per server request:
import { createScope, signal } from '@stateloom/core';
import { ScopeProvider, useSignal } from '@stateloom/react';
const count = signal(0);
function handleRequest() {
const scope = createScope();
scope.set(count, 42);
return renderToString(
<ScopeProvider scope={scope}>
<App />
</ScopeProvider>,
);
}Access the scope via useScopeContext():
import { useScopeContext } from '@stateloom/react';
function Debug() {
const scope = useScopeContext(); // Scope | null
return <pre>{scope ? 'Scoped' : 'Global'}</pre>;
}API Reference
useSignal<T>(subscribable: Subscribable<T>): T
Subscribe to a reactive value in a React component.
Parameters:
| Parameter | Type | Description | Default |
|---|---|---|---|
subscribable | Subscribable<T> | Any reactive source — signal, computed, or custom subscribable | — |
Returns: T — the current value. The component re-renders when the value changes.
import { signal, computed } from '@stateloom/core';
import { useSignal } from '@stateloom/react';
const count = signal(0);
const doubled = computed(() => count.get() * 2);
function App() {
const value = useSignal(count); // 0
const double = useSignal(doubled); // 0
// After count.set(5): value === 5, double === 10
}Key behaviors:
- Uses
useSyncExternalStorefor concurrent rendering compatibility - Uses
effect()from core to track computed signal dependencies - Inside a
ScopeProvider, the server snapshot reads from the scope for SSR hydration - Supports any object implementing the
Subscribable<T>interface
See also: ScopeProvider, useScopeContext()
ScopeProvider
Provide a StateLoom scope to descendant components for SSR isolation.
Props:
| Prop | Type | Required | Description |
|---|---|---|---|
scope | Scope | Yes | The scope to provide |
children | ReactNode | Yes | Child elements |
import { createScope } from '@stateloom/core';
import { ScopeProvider } from '@stateloom/react';
<ScopeProvider scope={createScope()}>
<App />
</ScopeProvider>;Key behaviors:
- Nesting is supported — inner providers override outer ones
- The scope is only used for
getServerSnapshotin SSR - On the client, signals read their global values normally
See also: useScopeContext()
ScopeContext
React context object for direct consumption via useContext. Prefer useScopeContext() instead.
import { ScopeContext } from '@stateloom/react';
const scope = useContext(ScopeContext); // Scope | nulluseScopeContext(): Scope | null
Read the current StateLoom scope from context.
Returns: Scope | null — the nearest ScopeProvider's scope, or null.
import { useScopeContext } from '@stateloom/react';
function MyComponent() {
const scope = useScopeContext();
if (scope) {
console.log('Rendering within a scope');
}
}See also: ScopeProvider
useStore<State>(store: StoreApi<State>): State
useStore<State, Selection>(store: StoreApi<State>, selector: (state: State) => Selection, equalityFn?: (a: Selection, b: Selection) => boolean): Selection
Subscribe to a store's state with optional selector memoization.
Import: @stateloom/react/store
Parameters:
| Parameter | Type | Description | Default |
|---|---|---|---|
store | StoreApi<State> | The store to subscribe to | — |
selector | (state: State) => Selection | Extract a derived value from the full state | (s) => s |
equalityFn | (a: Selection, b: Selection) => boolean | Custom equality for the selected value | Object.is |
Returns: State (no selector) or Selection (with selector).
import { createStore } from '@stateloom/store';
import { useStore } from '@stateloom/react/store';
const store = createStore((set) => ({
count: 0,
name: 'Alice',
increment: () => set((s) => ({ count: s.count + 1 })),
}));
// Full state
const { count, increment } = useStore(store);
// Selected slice
const count = useStore(store, (s) => s.count);
// Custom equality
const items = useStore(
store,
(s) => s.items,
(a, b) => a.length === b.length,
);Key behaviors:
- Without a selector, returns the full state (re-renders on any change)
- With a selector, only re-renders when the selected value changes
- Selector results are memoized — same state reference returns cached selection
- Uses
useSyncExternalStorefor concurrent rendering compatibility
See also: useSignal()
useAtom<T>(atom: Atom<T>): [T, (value: T) => void]
Subscribe to an atom's value and get a setter.
Import: @stateloom/react/atom
Parameters:
| Parameter | Type | Description | Default |
|---|---|---|---|
atom | Atom<T> | A base atom created by atom() | — |
Returns: [T, (value: T) => void] — a tuple of current value and stable setter (like useState).
import { atom } from '@stateloom/atom';
import { useAtom } from '@stateloom/react/atom';
const countAtom = atom(0);
function Counter() {
const [count, setCount] = useAtom(countAtom);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}Key behaviors:
- The setter is a stable reference (memoized on the atom identity)
- Re-renders when the atom value changes
See also: useAtomValue(), useSetAtom()
useAtomValue<T>(atom: AnyReadableAtom<T>): T
Subscribe to an atom's value (read-only).
Import: @stateloom/react/atom
Parameters:
| Parameter | Type | Description | Default |
|---|---|---|---|
atom | AnyReadableAtom<T> | Any readable atom — base, derived, or writable | — |
Returns: T — the current value.
import { atom, derived } from '@stateloom/atom';
import { useAtomValue } from '@stateloom/react/atom';
const countAtom = atom(0);
const doubledAtom = derived((get) => get(countAtom) * 2);
function Display() {
const doubled = useAtomValue(doubledAtom);
return <span>{doubled}</span>;
}Key behaviors:
- Works with base atoms, derived atoms, and writable atoms
- Uses
useSyncExternalStorefor concurrent rendering compatibility
See also: useAtom(), useSetAtom()
useSetAtom<T>(atom: Atom<T>): (value: T) => void
Get a stable setter function for an atom (write-only).
Import: @stateloom/react/atom
Parameters:
| Parameter | Type | Description | Default |
|---|---|---|---|
atom | Atom<T> | A base atom created by atom() | — |
Returns: (value: T) => void — a stable setter function.
import { atom } from '@stateloom/atom';
import { useSetAtom } from '@stateloom/react/atom';
const countAtom = atom(0);
function ResetButton() {
const setCount = useSetAtom(countAtom);
return <button onClick={() => setCount(0)}>Reset</button>;
}Key behaviors:
- The component does not subscribe to value changes — it will not re-render when the atom changes
- The setter is stable (memoized on atom identity) — safe to pass as a prop
See also: useAtom(), useAtomValue()
useSnapshot<T extends object>(proxy: T): Snapshot<T>
Subscribe to an observable proxy's snapshot.
Import: @stateloom/react/proxy
Parameters:
| Parameter | Type | Description | Default |
|---|---|---|---|
proxy | T | An observable proxy created by observable() | — |
Returns: Snapshot<T> — a deeply frozen, structurally shared snapshot.
import { observable } from '@stateloom/proxy';
import { useSnapshot } from '@stateloom/react/proxy';
const state = observable({ count: 0, name: 'Alice' });
function Display() {
const snap = useSnapshot(state);
return (
<div>
{snap.name}: {snap.count}
</div>
);
}
// Mutate directly
state.count++;Key behaviors:
- Snapshots are deeply frozen via
Object.freeze - Structural sharing — unchanged subtrees keep identical references
- Re-renders on any nested mutation of the proxy
- Uses
useSyncExternalStorefor concurrent rendering compatibility
See also: observable() from @stateloom/proxy
ScopeProviderProps
Props interface for the ScopeProvider component.
interface ScopeProviderProps {
readonly scope: Scope;
readonly children: ReactNode;
}Patterns
Computed Derived State
Use computed from core with useSignal for derived values:
import { signal, computed, batch } from '@stateloom/core';
import { useSignal } from '@stateloom/react';
const firstName = signal('Alice');
const lastName = signal('Smith');
const fullName = computed(() => `${firstName.get()} ${lastName.get()}`);
function NameDisplay() {
const name = useSignal(fullName);
return <h1>{name}</h1>;
}
// Batch updates to avoid intermediate renders
batch(() => {
firstName.set('Bob');
lastName.set('Jones');
});Store with Actions
import { createStore } from '@stateloom/store';
import { useStore } from '@stateloom/react/store';
const todoStore = createStore((set, get) => ({
todos: [] as string[],
add: (text: string) => set({ todos: [...get().todos, text] }),
remove: (index: number) =>
set({
todos: get().todos.filter((_, i) => i !== index),
}),
}));
function TodoList() {
const todos = useStore(todoStore, (s) => s.todos);
const { add, remove } = useStore(todoStore);
return (
<ul>
{todos.map((todo, i) => (
<li key={i}>
{todo}
<button onClick={() => remove(i)}>x</button>
</li>
))}
</ul>
);
}Atom-based Form
import { atom, derived } from '@stateloom/atom';
import { useAtom, useAtomValue } from '@stateloom/react/atom';
const emailAtom = atom('');
const passwordAtom = atom('');
const isValidAtom = derived((get) => {
return get(emailAtom).includes('@') && get(passwordAtom).length >= 8;
});
function LoginForm() {
const [email, setEmail] = useAtom(emailAtom);
const [password, setPassword] = useAtom(passwordAtom);
const isValid = useAtomValue(isValidAtom);
return (
<form>
<input value={email} onChange={(e) => setEmail(e.target.value)} />
<input value={password} onChange={(e) => setPassword(e.target.value)} type="password" />
<button disabled={!isValid}>Submit</button>
</form>
);
}How It Works
The React adapter bridges StateLoom's reactive graph to React's rendering cycle:
useSignalcallsuseSyncExternalStorewith asubscribefunction and agetSnapshotfunction- The
subscribefunction registers an external callback viasubscribable.subscribe()AND creates a StateLoomeffect()that callssubscribable.get()to track graph-level dependencies - When a dependency changes, the effect re-runs during batch flush, triggering computed signal refresh
- The computed's
#notifySubscribersfires the external subscribe callback, which callsonStoreChange - React calls
getSnapshot()to get the fresh value and re-renders if changed - On unmount, both the subscribe callback and the effect are cleaned up
The effect-based approach is necessary because computed signals use a lazy pull model — their external subscribers are only notified during .get() (which triggers refresh). The effect ensures .get() is called when graph dependencies change.
TypeScript
import { signal, computed } from '@stateloom/core';
import { useSignal } from '@stateloom/react';
import { useStore } from '@stateloom/react/store';
import { useAtom, useAtomValue, useSetAtom } from '@stateloom/react/atom';
import { useSnapshot } from '@stateloom/react/proxy';
// Type inferred from signal
const count = signal(42);
const value = useSignal(count);
// value: number
// Type inferred from computed
const doubled = computed(() => count.get() * 2);
const double = useSignal(doubled);
// double: number
// Type inferred from selector
const store = createStore(() => ({ count: 0, name: 'test' }));
const selected = useStore(store, (s) => s.count);
// selected: number
// Atom tuple type
const countAtom = atom(0);
const [val, set] = useAtom(countAtom);
// val: number, set: (value: number) => void
// Setter-only type
const setter = useSetAtom(countAtom);
// setter: (value: number) => void
// Snapshot type
const state = observable({ nested: { value: 1 } });
const snap = useSnapshot(state);
// snap: Snapshot<{ nested: { value: number } }>Migration
From Zustand
Zustand and @stateloom/react/store share a similar hook-based API with selectors:
// Zustand
import { create } from 'zustand';
const useStore = create((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}));
function Counter() {
const count = useStore((s) => s.count);
return <span>{count}</span>;
}
// StateLoom
import { createStore } from '@stateloom/store';
import { useStore } from '@stateloom/react/store';
const store = createStore((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}));
function Counter() {
const count = useStore(store, (s) => s.count);
return <span>{count}</span>;
}Key differences:
- Zustand's
createreturns a hook directly; StateLoom'screateStorereturns a store object passed touseStore - Zustand hooks auto-bind to a single store; StateLoom hooks are store-agnostic
- StateLoom supports middleware via the
Middleware<T>interface rather than Zustand's curried middleware
From Jotai
Jotai and @stateloom/react/atom share nearly identical API patterns:
// Jotai
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';
// StateLoom
import { atom, derived } from '@stateloom/atom';
import { useAtom, useAtomValue, useSetAtom } from '@stateloom/react/atom';
const countAtom = atom(0);
const doubledAtom = derived((get) => get(countAtom) * 2);
function Counter() {
const [count, setCount] = useAtom(countAtom);
const doubled = useAtomValue(doubledAtom);
return (
<span>
{count} x 2 = {doubled}
</span>
);
}Key differences:
- Jotai's
atomaccepts a read function for derived atoms; StateLoom uses a separatederived()function - Jotai stores atoms in a Provider; StateLoom atoms are standalone
Subscribable<T>objects - StateLoom's atom hooks are imported from
@stateloom/react/atom(sub-path)
From Valtio
Valtio and @stateloom/react/proxy share the mutable proxy + snapshot pattern:
// Valtio
import { proxy, useSnapshot } from 'valtio';
// StateLoom
import { observable } from '@stateloom/proxy';
import { useSnapshot } from '@stateloom/react/proxy';
const state = observable({ count: 0, user: { name: 'Alice' } });
function Display() {
const snap = useSnapshot(state);
return (
<div>
{snap.user.name}: {snap.count}
</div>
);
}
// Both: mutate directly
state.count++;Key differences:
- Valtio uses
proxy(); StateLoom usesobservable() - StateLoom's
useSnapshotis imported from@stateloom/react/proxy(sub-path) - StateLoom snapshots are created via
snapshot()with structural sharing
When to Use
| Scenario | Use |
|---|---|
| Single signal or computed value | useSignal(signal) |
| Store state with selector | useStore(store, selector) |
| Full store state | useStore(store) |
| Atom read + write | useAtom(atom) |
| Atom read-only (including derived) | useAtomValue(atom) |
| Atom write-only (no re-renders) | useSetAtom(atom) |
| Mutable proxy state | useSnapshot(proxy) |
| SSR scope isolation | ScopeProvider + useScopeContext() |
See the full React + Vite example app and Next.js SSR example for complete working applications.