Skip to content

@stateloom/react

React 18+ adapter for StateLoom — hooks bridging reactive signals, stores, atoms, and proxies to React components.

Install

bash
pnpm add @stateloom/react @stateloom/core
bash
npm install @stateloom/react @stateloom/core
bash
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)

Optional Paradigm Packages

Add paradigm packages as needed -- they are optional peer dependencies:

  • @stateloom/store for useStore (via @stateloom/react/store)
  • @stateloom/atom for useAtom/useAtomValue/useSetAtom (via @stateloom/react/atom)
  • @stateloom/proxy for useSnapshot (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

tsx
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:

tsx
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:

tsx
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:

tsx
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:

tsx
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 (like useState)
  • 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:

tsx
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:

tsx
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():

tsx
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:

ParameterTypeDescriptionDefault
subscribableSubscribable<T>Any reactive source — signal, computed, or custom subscribable

Returns: T — the current value. The component re-renders when the value changes.

tsx
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 useSyncExternalStore for 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:

PropTypeRequiredDescription
scopeScopeYesThe scope to provide
childrenReactNodeYesChild elements
tsx
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 getServerSnapshot in 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.

tsx
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.

tsx
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:

ParameterTypeDescriptionDefault
storeStoreApi<State>The store to subscribe to
selector(state: State) => SelectionExtract a derived value from the full state(s) => s
equalityFn(a: Selection, b: Selection) => booleanCustom equality for the selected valueObject.is

Returns: State (no selector) or Selection (with selector).

tsx
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 useSyncExternalStore for 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:

ParameterTypeDescriptionDefault
atomAtom<T>A base atom created by atom()

Returns: [T, (value: T) => void] — a tuple of current value and stable setter (like useState).

tsx
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:

ParameterTypeDescriptionDefault
atomAnyReadableAtom<T>Any readable atom — base, derived, or writable

Returns: T — the current value.

tsx
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 useSyncExternalStore for 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:

ParameterTypeDescriptionDefault
atomAtom<T>A base atom created by atom()

Returns: (value: T) => void — a stable setter function.

tsx
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:

ParameterTypeDescriptionDefault
proxyTAn observable proxy created by observable()

Returns: Snapshot<T> — a deeply frozen, structurally shared snapshot.

tsx
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 useSyncExternalStore for concurrent rendering compatibility

See also: observable() from @stateloom/proxy


ScopeProviderProps

Props interface for the ScopeProvider component.

typescript
interface ScopeProviderProps {
  readonly scope: Scope;
  readonly children: ReactNode;
}

Patterns

Computed Derived State

Use computed from core with useSignal for derived values:

tsx
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

tsx
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

tsx
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:

  1. useSignal calls useSyncExternalStore with a subscribe function and a getSnapshot function
  2. The subscribe function registers an external callback via subscribable.subscribe() AND creates a StateLoom effect() that calls subscribable.get() to track graph-level dependencies
  3. When a dependency changes, the effect re-runs during batch flush, triggering computed signal refresh
  4. The computed's #notifySubscribers fires the external subscribe callback, which calls onStoreChange
  5. React calls getSnapshot() to get the fresh value and re-renders if changed
  6. 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

tsx
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:

tsx
// 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 create returns a hook directly; StateLoom's createStore returns a store object passed to useStore
  • 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:

tsx
// 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 atom accepts a read function for derived atoms; StateLoom uses a separate derived() 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:

tsx
// 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 uses observable()
  • StateLoom's useSnapshot is imported from @stateloom/react/proxy (sub-path)
  • StateLoom snapshots are created via snapshot() with structural sharing

When to Use

ScenarioUse
Single signal or computed valueuseSignal(signal)
Store state with selectoruseStore(store, selector)
Full store stateuseStore(store)
Atom read + writeuseAtom(atom)
Atom read-only (including derived)useAtomValue(atom)
Atom write-only (no re-renders)useSetAtom(atom)
Mutable proxy stateuseSnapshot(proxy)
SSR scope isolationScopeProvider + useScopeContext()

See the full React + Vite example app and Next.js SSR example for complete working applications.