@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.
- The store is created outside React — its lifecycle is independent
- Business logic lives in subscribers, not event handlers
- Components declare what they read (
usePath) and what they publish (useIntent) - The provider is dependency injection, not a state container
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
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()
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)
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)
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)
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)
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:
| Namespace | Purpose | Hook |
|---|---|---|
state.* | Authoritative application state | usePath |
derived.* | Computed projections (pure functions of state) | usePath |
intent.* | Write-only signals from the UI | useIntent |
This is Model-View-Intent (MVI) inside a single object:
- state.* is the Model (single source of truth)
- derived.* is the ViewModel (formatted for the view)
- intent.* is the Controller (user actions)
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);
});