| name | h-state |
| description | Use when writing or editing React code that uses the `h-state` state management library (imports from 'h-state', calls createStore/useStore/batch, or uses store.$subscribe/$getState). Provides the exact API, idiomatic patterns, and the Proxy-free reactivity rules (tracked array methods, the index-assignment limitation, method-creator shape, selectors, persistence). |
| license | MIT |
h-state skill
h-state is a lightweight, Proxy-free React state library: mutate state directly and
components re-render automatically. Apply the rules below whenever generating h-state code.
Setup
import { createStore, batch } from 'h-state';
type State = { count: number; todos: { id: number; text: string; done: boolean }[] };
type Methods = { increment: () => void; addTodo: (text: string) => void };
export const { useStore, store } = createStore<State, Methods>(
{ count: 0, todos: [] },
{
increment: (s) => () => { s.count++; },
addTodo: (s) => (text: string) => {
s.todos.push({ id: Date.now(), text, done: false });
},
},
);
Core rules (must follow)
- Method shape is
(store) => (args) => void. Never (store) => { ... } directly.
- Mutate directly.
s.count++, s.user.name = 'x', s.items.push(x). No setState, no spreads required.
- Array mutation methods are tracked: push, pop, shift, unshift, splice, sort, reverse, fill, copyWithin — including nested mutation of freshly inserted elements (
s.items[i].done = true).
- NOT tracked (Proxy-free):
arr[0] = x and arr.length = n. Use splice or reassign (s.items = [...]).
- Root state is an object map (
Record<string, unknown>), never a primitive/array.
- Don't reassign the whole store; mutate fields or use
$merge / $reset.
In components
function View() {
const store = useStore();
const count = useStore((s) => s.count);
return <button onClick={store.increment}>{count}</button>;
}
For derived/object selectors pass an equality fn:
useStore((s) => s.todos.filter(t => !t.done), (a, b) => a.length === b.length && a.every((t,i)=>t===b[i])).
Selecting a whole array/object is fine in v2.11+: mutated containers get a fresh reference on the
next read, so the selector sees every change.
React Compiler (v2.11+)
h-state is safe under reactCompiler: true — do NOT add 'use no memo' directives. After a
mutation, the mutated container, its ancestors, and the no-selector useStore() result all return
NEW references on next read, so compiler/useMemo/React.memo/effect-dep comparisons (Object.is)
observe every change; untouched containers keep their identity. Captured references are snapshots —
re-read from the store after updates. Opt out per store with { identity: 'stable' } (4th arg)
only when the compiler is off and permanently stable references are required.
Outside React
const unsub = store.$subscribe((next, prev) => {});
store.$subscribeWithSelector((s) => s.user.name, (name) => {});
const snapshot = store.$getState();
unsub();
Time travel (undo / redo)
Opt in with the 4th createStore arg. History is OFF by default.
const { useStore, store } = createStore(initial, methods,
undefined,
{ history: true },
);
store.$undo();
store.$redo();
const { canUndo, canRedo, past, future } = store.$history();
store.$clearHistory();
Each committed change records one snapshot. Wrap multi-mutation actions in batch(...) so they
become a single undo step. A new change after undo clears the redo stack.
Cross-tab sync
Opt in with the 4th createStore arg. State stays in sync across tabs via BroadcastChannel.
const { useStore, store } = createStore(initial, methods,
undefined,
{ syncTabs: true },
);
store.$destroy();
Channel name defaults to the persistence key (or "h-state"). Remote updates don't echo back and
don't pollute undo history. No-ops on SSR / unsupported browsers.
Atomic transactions
$transaction(fn) runs mutations as one unit. If fn throws, every change rolls back and the
error re-throws. Commits as one re-render and one undo step; returns whatever fn returns.
const balance = store.$transaction(() => {
store.balance -= amount;
store.log.push(entry);
if (store.balance < 0) throw new Error('overdraft');
return store.balance;
});
Nested transactions are supported — an inner rollback won't undo the outer one.
Built-in store methods
$getState, $subscribe, $subscribeWithSelector, $merge(partial), $update,
$persist, $clearPersist, $reset, $undo, $redo, $history, $clearHistory, $destroy,
$transaction.
Batch & persistence
batch(() => { s.a = 1; s.b = 2; });
createStore(initial, methods, {
enabled: true, key: 'app', debounce: 300,
version: 2, migrate: (p, from) => from < 2 ? upgrade(p) : p,
});
See node_modules/h-state/AGENTS.md for the full contract and the ❌/✅ mistake list.