@stateloom/react
February 13, 2026 · View on GitHub
React 18+ adapter for StateLoom — hooks bridging reactive signals, stores, atoms, and proxies to React components.
Install
::: code-group
pnpm add @stateloom/react @stateloom/core
npm install @stateloom/react @stateloom/core
yarn add @stateloom/react @stateloom/core
:::
Size: ~1.1 KB gzipped (core entry), paradigm sub-paths add ~0.5-1 KB each
Requires: React 18+ (for useSyncExternalStore)
::: tip 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
graph LR
subgraph Core["@stateloom/core"]
signal["signal(value)"]
computed["computed(fn)"]
end
subgraph Paradigms
storeApi["StoreApi (@stateloom/store)"]
atomApi["Atom (@stateloom/atom)"]
proxyApi["observable (@stateloom/proxy)"]
end
subgraph React["@stateloom/react"]
useSignal["useSignal(source)"]
scopeCtx["ScopeProvider / useScopeContext"]
end
subgraph ReactStore["@stateloom/react/store"]
useStore["useStore(store, selector?)"]
end
subgraph ReactAtom["@stateloom/react/atom"]
useAtom["useAtom(atom)"]
useAtomValue["useAtomValue(atom)"]
useSetAtom["useSetAtom(atom)"]
end
subgraph ReactProxy["@stateloom/react/proxy"]
useSnapshot["useSnapshot(proxy)"]
end
signal --> useSignal
computed --> useSignal
storeApi --> useStore
atomApi --> useAtom
atomApi --> useAtomValue
atomApi --> useSetAtom
proxyApi --> useSnapshot
style useSignal fill:#61dafb,color:#000
style useStore fill:#61dafb,color:#000
style useAtom fill:#61dafb,color:#000
style useAtomValue fill:#61dafb,color:#000
style useSetAtom fill:#61dafb,color:#000
style useSnapshot fill:#61dafb,color:#000
style scopeCtx fill:#282c34,color:#fff
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 | null
useScopeContext(): 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:
sequenceDiagram
participant SL as StateLoom Signal
participant Eff as StateLoom Effect
participant USE as useSyncExternalStore
participant React as React
Note over Eff: Created inside subscribe()
Eff->>SL: signal.get() [tracks dependency]
USE->>SL: getSnapshot() = signal.get()
SL->>USE: returns value
USE->>React: initial render with value
Note over SL: signal.set(newValue)
SL->>Eff: effect re-runs (batch flush)
Eff->>SL: signal.get() [triggers computed refresh]
SL-->>USE: subscribe callback fires (onStoreChange)
USE->>SL: getSnapshot() = signal.get()
SL->>USE: returns newValue
USE->>React: re-render with newValue
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); // [!code --]
return <span>{count}</span>;
}
// StateLoom
import { createStore } from '@stateloom/store'; // [!code ++]
import { useStore } from '@stateloom/react/store'; // [!code ++]
const store = createStore((set) => ({
// [!code ++]
count: 0, // [!code ++]
increment: () => set((s) => ({ count: s.count + 1 })), // [!code ++]
})); // [!code ++]
function Counter() {
const count = useStore(store, (s) => s.count); // [!code ++]
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'; // [!code --]
// StateLoom
import { atom, derived } from '@stateloom/atom'; // [!code ++]
import { useAtom, useAtomValue, useSetAtom } from '@stateloom/react/atom'; // [!code ++]
const countAtom = atom(0);
const doubledAtom = derived((get) => get(countAtom) * 2); // [!code ++]
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'; // [!code --]
// StateLoom
import { observable } from '@stateloom/proxy'; // [!code ++]
import { useSnapshot } from '@stateloom/react/proxy'; // [!code ++]
const state = observable({ count: 0, user: { name: 'Alice' } }); // [!code ++]
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.