@everystate/react 1.0.6
React adapter for @everystate/core. Five hooks and a provider - that's the entire API. ~50 lines, zero dependencies beyond React and the core store.
Install
npm install @everystate/react @everystate/core react
Peer dependencies: @everystate/core >=1.0.5 and react >=18.0.0.
Quick Start
import { createEveryState } from '@everystate/core';
import { EventStateProvider, usePath, useIntent } from '@everystate/react';
// Store lives outside React
const store = createEveryState({
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 { createEveryState } from '@everystate/core';
// store.js - created once, outside any component
export const store = createEveryState({ state: { tasks: [] } });
// App.jsx
import { EventStateProvider } from '@everystate/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.
useStore()
Returns the EveryState 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.
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. 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.
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.
Three Namespaces
| Namespace | Purpose | Hook |
|---|---|---|
state.* | Authoritative application state | usePath |
derived.* | Computed projections (pure functions of state) | usePath |
intent.* | Write-only signals from the UI | useIntent |
Business Logic Outside Components
// 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);
});
Derived State
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();
Components read from derived.* - they never compute:
const items = usePath('derived.tasks.filtered');
Race Condition Handling
function SearchResults() {
const { data, execute } = useAsync('search');
const query = usePath('state.searchQuery');
useEffect(() => {
if (!query) return;
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
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);
});