@uistate/react 1.0.0

React adapter for @uistate/core. Five hooks and a provider — that's the entire API. ~50 lines, zero dependencies beyond React and the core store.

Install

npm install @uistate/react @uistate/core react

Peer dependencies: @uistate/core >=5.0.0 and react >=18.0.0.

Quick Start

import { createEventState } from '@uistate/core';
import { EventStateProvider, usePath, useIntent } from '@uistate/react';

// Store lives outside React
const store = createEventState({
  state: { count: 0 },
});

// Business logic lives outside React
store.subscribe('intent.increment', () => {
  store.set('state.count', store.get('state.count') + 1);
});

function Counter() {
  const count = usePath('state.count');
  const increment = useIntent('intent.increment');
  return <button onClick={() => increment(true)}>Count: {count}</button>;
}

function App() {
  return (
    <EventStateProvider store={store}>
      <Counter />
    </EventStateProvider>
  );
}

Architecture

The key insight: React is a rendering engine, not the application architecture.

This means you can test all business logic without rendering a single component, share state across multiple React trees (or non-React UIs), and swap the view layer without touching state code.

EventStateProvider

<EventStateProvider store={store}> children </EventStateProvider>

Makes a store available to descendant hooks via React Context. The store is created outside React — the provider doesn't own, create, or destroy it.

import { createEventState } from '@uistate/core';

// store.js — created once, outside any component
export const store = createEventState({ state: { tasks: [] } });

// App.jsx
import { EventStateProvider } from '@uistate/react';
import { store } from './store.js';

export default function App() {
  return (
    <EventStateProvider store={store}>
      <Header />
      <TaskList />
    </EventStateProvider>
  );
}

Why not just import the store directly? You can. The provider makes testing easier (inject a mock store) and makes the dependency explicit. Either approach works.

useStore()

useStore() → store

Returns the EventState store from context. Throws if called outside a provider.

const store = useStore();
const value = store.get('some.path');

You rarely need this — usePath and useIntent cover most cases. Use useStore when you need direct access for advanced patterns.

usePath(path)

usePath(path: string) → any

Subscribe to a dot-path. Re-renders the component only when the value at that path changes. Uses React 18's useSyncExternalStore for concurrent-mode safety.

function Header() {
  const count = usePath('state.taskCount') || 0;
  return <span>{count} task{count === 1 ? '' : 's'}</span>;
}

function TaskList() {
  const items = usePath('derived.tasks.filtered') || [];
  return <ul>{items.map(t => <li key={t.id}>{t.text}</li>)}</ul>;
}

This is the "read" side of the contract. No props, no selectors, no context consumers — just a path.

useIntent(path)

useIntent(path: string) → (value: any) → any

Returns a stable, memoized function that publishes a value to a path. Safe to pass as a prop without causing re-renders.

function TaskInput() {
  const [text, setText] = useState('');
  const add = useIntent('intent.addTask');

  return <input
    value={text}
    onKeyDown={(e) => {
      if (e.key === 'Enter') { add(text); setText(''); }
    }}
  />;
}

This is the "write" side. The component publishes intent. A subscriber handles the logic.

useWildcard(path)

useWildcard(wildcardPath: string) → any

Subscribe to a wildcard path. Re-renders when any child of that path changes. Returns the parent object.

function UserCard() {
  const user = useWildcard('state.user.*');
  // Re-renders when state.user.name, state.user.email, etc. change
  return <div>{user?.name} ({user?.email})</div>;
}

useAsync(path)

useAsync(path: string) → { data, status, error, execute, cancel }

Async data fetching with automatic status tracking. Wraps store.setAsync and subscribes to the .data, .status, and .error sub-paths.

function UserList() {
  const { data, status, error, execute, cancel } = useAsync('users');

  useEffect(() => {
    execute((signal) =>
      fetch('/api/users', { signal }).then(r => r.json())
    );
  }, [execute]);

  if (status === 'loading') return <Spinner />;
  if (error) return <p>Error: {error}</p>;
  return <ul>{data?.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}

Calling execute again auto-aborts the previous in-flight request. No race conditions, no stale data, no cleanup code.

Three Namespaces

The recommended convention for structuring your store:

NamespacePurposeHook
state.*Authoritative application stateusePath
derived.*Computed projections (pure functions of state)usePath
intent.*Write-only signals from the UIuseIntent

This is Model-View-Intent (MVI) inside a single object:

const store = createEventState({
  state: { tasks: [], taskCount: 0, filter: 'all' },
  derived: { tasks: { filtered: [] } },
});

Business Logic Outside Components

In a typical React app, logic lives in event handlers inside components. In EventState + React, logic lives in subscribers — testable, reusable, and decoupled:

// store.js — no React imports
store.subscribe('intent.addTask', (text) => {
  const t = String(text || '').trim();
  if (!t) return;
  const tasks = store.get('state.tasks') || [];
  const next = [...tasks, { id: genId(), text: t, completed: false }];
  store.set('state.tasks', next);
  store.set('state.taskCount', next.length);
});

store.subscribe('intent.toggleTask', (id) => {
  const tasks = store.get('state.tasks') || [];
  store.set('state.tasks',
    tasks.map(x => x.id === id ? { ...x, completed: !x.completed } : x)
  );
});

Test it without React:

store.set('intent.addTask', 'test task');
assert(store.get('state.taskCount') === 1);

Derived State

Computed values are subscribers that write to derived.*:

function recomputeDerived() {
  const tasks = store.get('state.tasks') || [];
  const filter = store.get('state.filter');
  store.set('derived.tasks.filtered', filterTasks(tasks, filter));
}

store.subscribe('state.tasks', recomputeDerived);
store.subscribe('state.filter', recomputeDerived);
recomputeDerived(); // initial computation

Components read from derived.* — they never compute:

const items = usePath('derived.tasks.filtered');

Race Condition Handling

useAsync + store.setAsync auto-aborts previous in-flight requests. This solves the stale-fetch problem that plagues other frameworks:

function SearchResults() {
  const { data, execute } = useAsync('search');
  const query = usePath('state.searchQuery');

  useEffect(() => {
    if (!query) return;
    // Auto-aborts previous request when query changes
    execute((signal) =>
      fetch(`/api/search?q=${query}`, { signal }).then(r => r.json())
    );
  }, [query, execute]);

  return <ul>{data?.map(r => <li key={r.id}>{r.title}</li>)}</ul>;
}

No cancelled boolean. No cleanup function. No stale closures. The store owns the abort lifecycle.

Testing Without React

Because logic lives in subscribers, you can test the entire app without render() or screen.getByRole():

import { store } from './store.js';

test('adding a task increments taskCount', () => {
  const before = store.get('state.taskCount');
  store.set('intent.addTask', 'test task');
  expect(store.get('state.taskCount')).toBe(before + 1);
});

test('filter shows only active tasks', () => {
  store.set('intent.addTask', 'task 1');
  store.set('intent.addTask', 'task 2');
  store.set('intent.toggleTask', store.get('state.tasks')[0].id);
  store.set('intent.changeFilter', 'active');
  expect(store.get('derived.tasks.filtered')).toHaveLength(1);
});