Skip to content

React + Vite

Integrate StateLoom with a React application using Vite. Covers hooks for signals, stores, atoms, and proxies.

Data Flow

Prerequisites

  • Node.js 18+
  • React 18+ (for useSyncExternalStore)
  • Vite 5+

Project Setup

Scaffold a new React + Vite project and install StateLoom:

bash
pnpm create vite my-app --template react-ts
cd my-app

Install the core and React adapter:

bash
pnpm add @stateloom/core @stateloom/react
bash
npm install @stateloom/core @stateloom/react
bash
yarn add @stateloom/core @stateloom/react

Add a paradigm adapter based on your preference:

bash
pnpm add @stateloom/store
bash
pnpm add @stateloom/atom
bash
pnpm add @stateloom/proxy

Basic Integration

Signals with useSignal

Bridge core signals to React components. This is the simplest integration -- no paradigm adapter needed:

typescript
// src/state/counter.ts
import { signal, computed } from '@stateloom/core';

export const count = signal(0);
export const doubled = computed(() => count.get() * 2);
tsx
// src/components/Counter.tsx
import { useSignal } from '@stateloom/react';
import { batch } from '@stateloom/core';
import { count, doubled } from '../state/counter';

export function Counter() {
  const value = useSignal(count);
  const double = useSignal(doubled);

  return (
    <section>
      <h2>Counter</h2>
      <p>
        Count: {value} | Doubled: {double}
      </p>
      <button onClick={() => count.set(value - 1)}>-1</button>
      <button onClick={() => count.set(value + 1)}>+1</button>
      <button
        onClick={() => {
          batch(() => {
            for (let i = 0; i < 5; i++) count.set(count.get() + 1);
          });
        }}
      >
        +5 (batched)
      </button>
      <button onClick={() => count.set(0)}>Reset</button>
    </section>
  );
}

useSignal accepts any Subscribable<T> (signals, computed, atoms) and returns the current value. The component re-renders only when the value changes.

Stores with useStore

For feature-scoped state with actions and middleware support:

typescript
// src/state/todos.ts
import { createStore } from '@stateloom/store';
import { logger } from '@stateloom/devtools';
import { persist, memoryStorage } from '@stateloom/persist';

type Todo = {
  readonly id: number;
  readonly text: string;
  readonly done: boolean;
};

type TodoState = {
  readonly items: readonly Todo[];
  readonly nextId: number;
  readonly add: (text: string) => void;
  readonly toggle: (id: number) => void;
  readonly remove: (id: number) => void;
};

export const todoStore = createStore<TodoState>(
  (set) => ({
    items: [],
    nextId: 1,
    add: (text: string) =>
      set((s) => ({
        items: [...s.items, { id: s.nextId, text, done: false }],
        nextId: s.nextId + 1,
      })),
    toggle: (id: number) =>
      set((s) => ({
        items: s.items.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
      })),
    remove: (id: number) =>
      set((s) => ({
        items: s.items.filter((t) => t.id !== id),
      })),
  }),
  {
    middleware: [
      logger<TodoState>({ name: 'TodoStore', diff: true }),
      persist<TodoState>({
        key: 'stateloom-todos',
        storage: memoryStorage(),
        partialize: (s) => ({ items: s.items, nextId: s.nextId }),
      }),
    ],
  },
);
tsx
// src/components/TodoList.tsx
import { useState } from 'react';
import { useStore } from '@stateloom/react/store';
import { todoStore } from '../state/todos';

export function TodoList() {
  const { items, add, toggle, remove } = useStore(todoStore);
  const [text, setText] = useState('');

  const handleAdd = () => {
    const trimmed = text.trim();
    if (trimmed === '') return;
    add(trimmed);
    setText('');
  };

  return (
    <section>
      <h2>Todo List</h2>
      <input
        value={text}
        onChange={(e) => setText(e.target.value)}
        onKeyDown={(e) => {
          if (e.key === 'Enter') handleAdd();
        }}
        placeholder="Add a todo..."
      />
      <button onClick={handleAdd}>Add</button>
      <ul>
        {items.map((item) => (
          <li key={item.id}>
            <span onClick={() => toggle(item.id)}>{item.text}</span>
            <button onClick={() => remove(item.id)}>x</button>
          </li>
        ))}
      </ul>
    </section>
  );
}

Use Selectors

Pass a selector to useStore to prevent re-renders when unrelated state changes:

tsx
// Only re-renders when count changes, not when name changes
const count = useStore(counterStore, (s) => s.count);

Atoms with useAtom

For bottom-up, composable state:

typescript
// src/state/profile.ts
import { atom, derived } from '@stateloom/atom';

export const nameAtom = atom('Alice');
export const ageAtom = atom(30);
export const summaryAtom = derived((get) => `${get(nameAtom)}, age ${String(get(ageAtom))}`);
tsx
// src/components/UserProfile.tsx
import { useAtom } from '@stateloom/react/atom';
import { useSignal } from '@stateloom/react';
import { nameAtom, ageAtom, summaryAtom } from '../state/profile';

export function UserProfile() {
  const [name, setName] = useAtom(nameAtom);
  const [age, setAge] = useAtom(ageAtom);
  const summary = useSignal(summaryAtom);

  return (
    <section>
      <h2>User Profile</h2>
      <label>
        Name: <input value={name} onChange={(e) => setName(e.target.value)} />
      </label>
      <label>
        Age:{' '}
        <input
          type="number"
          value={age}
          onChange={(e) => {
            const n = parseInt(e.target.value, 10);
            if (!Number.isNaN(n)) setAge(n);
          }}
        />
      </label>
      <p>
        Summary: <strong>{summary}</strong>
      </p>
    </section>
  );
}

Proxy with useSnapshot

For mutable-style state management:

typescript
// src/state/settings.ts
import { observable } from '@stateloom/proxy';

export const settings = observable({
  theme: 'light' as 'light' | 'dark',
  fontSize: 16,
  notifications: true,
});
tsx
// src/components/ProxyState.tsx
import { useSnapshot } from '@stateloom/react/proxy';
import { settings } from '../state/settings';

export function ProxyState() {
  const snap = useSnapshot(settings);

  return (
    <section>
      <h2>Settings</h2>
      <button
        onClick={() => {
          settings.theme = settings.theme === 'light' ? 'dark' : 'light';
        }}
      >
        Toggle Theme
      </button>
      <button
        onClick={() => {
          settings.fontSize += 2;
        }}
      >
        Font +
      </button>
      <button
        onClick={() => {
          settings.fontSize -= 2;
        }}
      >
        Font -
      </button>
      <button
        onClick={() => {
          settings.notifications = !settings.notifications;
        }}
      >
        Toggle Notifications
      </button>
      <pre>{JSON.stringify(snap, null, 2)}</pre>
    </section>
  );
}

Patterns

Middleware Stack

Add devtools, persistence, and logging to any store:

typescript
import { createStore } from '@stateloom/store';
import { devtools } from '@stateloom/devtools';
import { persist } from '@stateloom/persist';

const store = createStore(
  (set) => ({
    theme: 'light' as 'light' | 'dark',
    setTheme: (t: 'light' | 'dark') => set({ theme: t }),
  }),
  {
    middleware: [
      devtools({ name: 'Theme', enabled: import.meta.env.DEV }),
      persist({ key: 'theme-prefs' }),
    ],
  },
);

Multiple Stores

Define stores in separate files and compose them in components:

tsx
import { useStore } from '@stateloom/react/store';
import { authStore } from './stores/auth';
import { settingsStore } from './stores/settings';

function Header() {
  const userName = useStore(authStore, (s) => s.user?.name);
  const theme = useStore(settingsStore, (s) => s.theme);

  return (
    <header className={theme}>
      <span>Hello, {userName ?? 'Guest'}</span>
    </header>
  );
}

Migrating from Zustand

If you are migrating from Zustand, the store API is intentionally similar:

typescript
// Zustand
import { create } from 'zustand';
const useStore = create((set) => ({
  count: 0,
  inc: () => set((s) => ({ count: s.count + 1 })),
}));

// StateLoom
import { createStore } from '@stateloom/store';
const counterStore = createStore((set) => ({
  count: 0,
  inc: () => set((s) => ({ count: s.count + 1 })),
}));
// Use with: useStore(counterStore, (s) => s.count)

Key differences:

  • createStore returns a store object, not a hook. Pass it to useStore from @stateloom/react/store.
  • Middleware is passed as an array in options, not as function wrapping.

Tips

Define Stores Outside Components

Create stores at module level, not inside components. This prevents re-creation on every render and enables sharing across components.

Don't Mix React State and StateLoom

Use useState/useReducer for local UI state (form inputs, open/closed toggles). Use StateLoom for state shared across components.

Example App

See the complete React + Vite example for a working app that demonstrates all four paradigms together.

Next Steps

Live Demo

Try the React + Vite example directly in your browser:

Open in StackBlitz | Open Static Demo