h-state
v2.8.0
Published
Lightweight Proxy-free React state management. Direct mutations, tracked array methods, built-in undo/redo time travel, cross-tab sync, atomic transactions, and localStorage persistence. Zero dependencies, ~2KB, TypeScript-first.
Maintainers
Readme
H-State
A lightweight and intuitive state management library for React with deep nested reactivity, built on signals and getter/setter patterns for optimal performance.
🤖 AI / LLM support: This package ships first-class guidance for AI coding agents.
AGENTS.md(exact API, patterns, and ❌/✅ mistakes) andllms.txtare bundled in the npm tarball, so Cursor / Claude Code / Copilot / Codex can read them fromnode_modules/h-state/. An installable Agent Skill lives inskills/h-state/SKILL.md.
Features
Everything below ships in the box — no plugins, no middleware, no providers.
- ✨ Direct-mutation reactivity — write
store.count++,store.user.name = 'x',store.items.push(y)and components re-render automatically. No reducers, no actions, noset(). - 🧬 Deep nested + array reactivity — unlimited object depth, no Proxy overhead. Tracked array methods (
push / pop / shift / unshift / splice / sort / reverse / fill / copyWithin) re-render and persist automatically;Array.isArraystaystrue. - 🧩 Fine-grained selectors —
useStore(selector, equalityFn?)re-renders only when the selected slice changes. Built onuseSyncExternalStore, safe with React 18 concurrent features. - 🔌 Use outside React —
createStorealso returns the livestore:$getState(),$subscribe(), and$subscribeWithSelector()for loggers, WebSocket bridges, tests, anywhere. - ⏳ Time travel (undo / redo) — opt in with
{ history: true }:$undo(),$redo(),$history(),$clearHistory(). Works with primitives, objects, and arrays. - 📡 Cross-tab sync — opt in with
{ syncTabs: true }: state stays live across every tab/window viaBroadcastChannel. No server, no extra deps.$destroy()closes the channel. - � Atomic transactions —
$transaction(fn)runs a batch of mutations as one unit; iffnthrows, every change is rolled back automatically. Commits as a single re-render and a single undo step. - 💾 Persistence with migrations —
localStorageout of the box withversion+migrate, deep-merge of new fields, microtask-coalesced writes, custom serialize/deserialize. - 🪶 Batching —
batch(fn)collapses many mutations into a single re-render/flush. - 🎯 Type-safe — full TypeScript inference for state and methods.
- � Zero dependencies — ~2KB gzipped, tree-shakeable, SSR-safe. React is the only peer.
- 🤖 First-class AI support —
AGENTS.md,llms.txt, and an installable Agent Skill ship in the tarball.
Installation
npm install h-state
# or
yarn add h-stateQuick Start
import { createStore } from 'h-state';
// 1. Define your state structure
interface CounterState {
count: number;
}
// 2. Define your methods
interface CounterMethods {
increment: () => void;
decrement: () => void;
}
// 3. Create your store
const { useStore } = createStore<CounterState, CounterMethods>(
{
count: 0,
},
{
increment: (store) => () => {
store.count++;
},
decrement: (store) => () => {
store.count--;
},
}
);
// 4. Use in your React components
function Counter() {
const store = useStore();
return (
<div>
<button onClick={store.decrement}>-</button>
<span>Count: {store.count}</span>
<button onClick={store.increment}>+</button>
</div>
);
}Examples
Our live demo includes several examples:
- 📊 Basic Counter
- 👤 User Profile Management
- ✅ Todo List
- 🔄 Nested State Updates
- 📝 Form Handling
- 💾 localStorage Persistence
Complete Todo List Example
import { createStore } from 'h-state';
// Define types
interface TodoState {
todos: string[];
newTodo: string;
}
interface TodoMethods {
addTodo: () => void;
removeTodo: (index: number) => void;
}
// Create store
const { useStore } = createStore<TodoState, TodoMethods>(
{
todos: ['Learn H-State', 'Build awesome apps'],
newTodo: '',
},
{
addTodo: (store) => () => {
if (store.newTodo.trim()) {
store.todos = [...store.todos, store.newTodo];
store.newTodo = '';
}
},
removeTodo: (store) => (index: number) => {
store.todos = store.todos.filter((_, i) => i !== index);
},
}
);
// Use in component
function TodoList() {
const store = useStore();
return (
<div>
<input
type="text"
value={store.newTodo}
onChange={(e) => (store.newTodo = e.target.value)}
placeholder="Add a new todo..."
/>
<button onClick={store.addTodo}>Add</button>
<ul>
{store.todos.map((todo, index) => (
<li key={index}>
{todo}
<button onClick={() => store.removeTodo(index)}>Delete</button>
</li>
))}
</ul>
</div>
);
}User Profile with Nested State
import { createStore } from 'h-state';
interface UserState {
user: {
name: string;
age: number;
};
}
interface UserMethods {}
const { useStore } = createStore<UserState, UserMethods>(
{
user: {
name: 'John Doe',
age: 25,
},
},
{}
);
function UserProfile() {
const store = useStore();
return (
<div>
<input
type="text"
value={store.user.name}
onChange={(e) => {
// Deep reactivity - just update nested property!
store.user.name = e.target.value;
}}
/>
<input
type="number"
value={store.user.age}
onChange={(e) => {
store.user.age = parseInt(e.target.value);
}}
/>
<p>User: {store.user.name}, Age: {store.user.age}</p>
</div>
);
}Deep Nested Reactivity (v2.0+)
const { useStore } = createStore(
{
user: {
name: '',
profile: {
bio: '',
settings: {
theme: 'light'
}
}
}
},
{
// Methods have access to store
updateTheme: (store) => (theme: string) => {
store.user.profile.settings.theme = theme;
}
}
);
function Component() {
const store = useStore();
// All nested updates are reactive!
store.user.name = 'John'; // ✅ Reactive
store.user.profile.bio = 'Developer'; // ✅ Reactive
store.user.profile.settings.theme = 'dark'; // ✅ Reactive
return <div>{store.user.profile.settings.theme}</div>;
}localStorage Persistence (v2.1+) 💾
import { createStore } from 'h-state';
interface AppState {
count: number;
user: {
name: string;
};
}
interface AppMethods {
increment: () => void;
}
// Persisted store - automatically saved to localStorage!
const { useStore } = createStore<AppState, AppMethods>(
{
count: 0,
user: { name: 'John' },
},
{
increment: (store) => () => {
store.count++;
},
},
{
enabled: true, // Enable persistence
key: 'my-app-state', // localStorage key
debounce: 300, // Save after 300ms of inactivity
}
);
function App() {
const store = useStore();
return (
<div>
<p>Count: {store.count}</p>
<button onClick={store.increment}>+</button>
<input
value={store.user.name}
onChange={(e) => store.user.name = e.target.value}
/>
{/* Manual controls */}
<button onClick={() => store.$persist()}>Save Now</button>
<button onClick={() => store.$clearPersist()}>Clear Storage</button>
</div>
);
}
// Try it: Make changes, reload the page - your state persists! ✨Compare: Persisted vs Non-Persisted
// Non-persisted (default)
const { useStore: useRegularStore } = createStore(
{ count: 0 },
{}
);
// Persisted
const { useStore: usePersistedStore } = createStore(
{ count: 0 },
{},
{ enabled: true, key: 'persisted-count' }
);
function Comparison() {
const regular = useRegularStore();
const persisted = usePersistedStore();
return (
<div>
<div>
<h3>❌ Regular (Lost on reload)</h3>
<button onClick={() => regular.count++}>
Count: {regular.count}
</button>
</div>
<div>
<h3>✅ Persisted (Saved to localStorage)</h3>
<button onClick={() => persisted.count++}>
Count: {persisted.count}
</button>
</div>
</div>
);
}Batch Updates for Performance
import { createStore, batch } from 'h-state';
const { useStore } = createStore(
{
items: [] as string[],
count: 0,
status: 'idle'
},
{
loadData: (store) => async () => {
// Multiple updates in single re-render
batch(() => {
store.items = ['item1', 'item2', 'item3'];
store.count = 3;
store.status = 'loaded';
}); // Only 1 re-render!
}
}
);Utility Methods
const { useStore } = createStore(
{ count: 0, name: '' },
{}
);
function Component() {
const store = useStore();
// $merge - batch update multiple properties
store.$merge({ count: 5, name: 'John' }); // Single re-render
// $update - force manual re-render (rarely needed)
store.$update();
}API Reference
createStore(initialState, methods, persistOptions?)
Creates a new store with reactive state and methods.
function createStore<T, M>(
initialState: T,
methods: MethodCreators<T, M>,
persistOptions?: PersistOptions
): { useStore: () => StoreType<T, M> }Parameters:
initialState:T- Object containing initial state propertiesmethods:MethodCreators<T, M>- Object with method creators that receive store as first parameterpersistOptions(optional):PersistOptions- localStorage persistence configuration
Returns:
{ useStore }: React hook to access the store
Example:
const { useStore } = createStore(
{ count: 0 }, // Initial state
{
increment: (store) => () => { // Method creator
store.count++;
}
},
{ // Persistence options (optional)
enabled: true,
key: 'my-app-count'
}
);PersistOptions
Configuration for localStorage persistence:
interface PersistOptions {
enabled?: boolean; // Enable persistence (default: false)
key?: string; // localStorage key (auto-generated if not provided)
debounce?: number; // Debounce save in ms (default: 0 - immediate)
serialize?: (state) => string; // Custom serializer (default: JSON.stringify)
deserialize?: (data) => object; // Custom deserializer (default: JSON.parse)
onError?: (error: Error) => void; // Error handler (default: console.error)
}Example with all options:
const { useStore } = createStore(
{ data: [] },
{},
{
enabled: true,
key: 'my-custom-key',
debounce: 500,
serialize: (state) => JSON.stringify(state),
deserialize: (data) => JSON.parse(data),
onError: (error) => console.error('Persist error:', error)
}
);batch(fn)
Groups multiple state updates into a single re-render.
function batch<T>(fn: () => T): TParameters:
fn: Function containing multiple state updates
Returns:
- Return value of the function
Example:
batch(() => {
store.name = 'John';
store.age = 25;
store.email = '[email protected]';
}); // Only 1 re-render instead of 3!Store Methods
Every store instance includes:
$merge(partial): Batch update multiple properties$update(): Manually trigger re-render$persist(): Force immediate save to localStorage (if persistence enabled)$clearPersist(): Clear persisted data from localStorage$reset(): Restore initial state and clear persisted payload$getState(): Plain, non-reactive deep snapshot (state keys only)$subscribe(listener): Subscribe to any change outside React → unsubscribe fn$subscribeWithSelector(selector, listener, equalityFn?): Subscribe to a derived slice → unsubscribe fn$undo()/$redo(): Time travel (requires{ history: true }) → returnstrueif a step was taken$history():{ canUndo, canRedo, past, future }$clearHistory(): Empty the undo/redo stacks$destroy(): Close the cross-tabBroadcastChannel(requires{ syncTabs: true })$transaction(fn): Run mutations atomically; rolls back all changes iffnthrows → returnsfn's result
Example:
const { useStore } = createStore(
{ count: 0, name: '' },
{},
{ enabled: true, key: 'my-state' }
);
function Component() {
const store = useStore();
// Batch update
store.$merge({ count: 5, name: 'John' });
// Force save immediately (bypasses debounce)
store.$persist();
// Clear persisted data
const handleReset = () => {
store.$clearPersist();
window.location.reload(); // Reload to show initial state
};
return <button onClick={handleReset}>Reset & Reload</button>;
}Reactive Arrays
Array mutations are tracked automatically — no need to clone on every change:
const { useStore } = createStore(
{ todos: [] as Todo[] },
{
addTodo: (store) => (todo: Todo) => {
store.todos.push(todo); // ✅ triggers re-render + persist
},
removeAt: (store) => (i: number) => {
store.todos.splice(i, 1); // ✅ triggers re-render + persist
},
togglePinned: (store) => (i: number) => {
store.todos[i].pinned = !store.todos[i].pinned; // ✅ nested mutation tracked
},
}
);Tracked mutation methods: push, pop, shift, unshift, splice, sort, reverse, fill, copyWithin.
Not tracked (Proxy-free design limitation): direct index assignment arr[0] = x and arr.length = n. Use splice or reassign the array:
store.todos[0] = newTodo; // ❌ no re-render
store.todos.splice(0, 1, newTodo); // ✅ use this instead
store.todos = [newTodo, ...store.todos]; // ✅ or reassignSelector-Based Subscriptions
By default useStore() re-renders on any state change. For fine-grained subscriptions pass a selector:
// Re-renders only when `count` changes
const count = useStore((s) => s.count);
// Custom equality for derived/object selectors
const visibleTodos = useStore(
(s) => s.todos.filter((t) => !t.done),
(a, b) => a.length === b.length && a.every((t, i) => t === b[i])
);Selectors use useSyncExternalStore under the hood — safe with React 18 concurrent features.
Why h-state? (comparison)
| | h-state | Zustand | Redux Toolkit | Jotai |
|---|:---:|:---:|:---:|:---:|
| Direct mutation (store.count++) | ✅ | ❌ (set) | ❌ (reducers) | ❌ (atoms) |
| Tracked array methods (push/splice) | ✅ | ❌ | ❌ | ❌ |
| Built-in undo/redo (time travel) | ✅ | ❌ (middleware) | ❌ (middleware) | ❌ |
| Cross-tab sync | ✅ | ❌ (middleware) | ❌ | ❌ |
| Atomic transactions (auto rollback) | ✅ | ❌ | ❌ | ❌ |
| localStorage persistence + migrations | ✅ | ⚠️ (middleware) | ⚠️ | ⚠️ |
| Proxy-free | ✅ | ✅ | ✅ | ✅ |
| Dependencies | 0 | 0 | several | 0 |
| Ships AGENTS.md for AI agents | ✅ | ❌ | ❌ | ❌ |
No reducers, no actions, no providers. Mutate state and it just re-renders.
Time Travel (undo / redo)
Opt in with the 4th createStore argument and get undo/redo with no extra libraries:
const { useStore, store } = createStore<State, Methods>(
{ text: '', items: [] as string[] },
{
setText: (s) => (t: string) => { s.text = t; },
addItem: (s) => (i: string) => { s.items.push(i); },
},
undefined, // persistOptions (3rd arg)
{ history: true }, // 👈 enable time travel (or { history: { limit: 50 } })
);
function Editor() {
const store = useStore();
const { canUndo, canRedo } = store.$history();
return (
<>
<input value={store.text} onChange={(e) => store.setText(e.target.value)} />
<button disabled={!canUndo} onClick={store.$undo}>Undo</button>
<button disabled={!canRedo} onClick={store.$redo}>Redo</button>
</>
);
}Notes
- Each committed change records a snapshot. Group multiple mutations with
batch(...)to record one step. - A new change after an undo clears the redo stack (linear history, like every editor).
limitcaps the number of retained past snapshots (default100).- History is off by default — zero overhead unless you enable it.
Cross-Tab Sync
Keep state consistent across every open tab/window with one option — powered by the browser's BroadcastChannel, no server required:
const { useStore, store } = createStore<State, Methods>(
{ theme: 'dark', cart: [] as string[] },
{
setTheme: (s) => (t: string) => { s.theme = t; },
addToCart: (s) => (id: string) => { s.cart.push(id); },
},
undefined, // persistOptions (3rd arg)
{ syncTabs: true }, // 👈 sync across tabs (or { syncTabs: { channel: 'my-app' } })
);
// Change in tab A → instantly reflected in tab B, C, …
store.addToCart('sku-1');
// When you're done (e.g. on unmount in a micro-frontend):
store.$destroy();Notes
- The channel name defaults to your persistence
keyif set, otherwise"h-state". Pass{ syncTabs: { channel } }to namespace multiple stores. - Remote updates are applied without re-broadcasting (no feedback loops) and don't pollute undo history.
- Combine with
{ enabled: true }persistence so a brand-new tab loads the last state, then stays live via sync. - Gracefully no-ops during SSR or in browsers without
BroadcastChannel.
Atomic Transactions
Run a group of mutations as a single unit. If anything throws, every change is rolled back to the pre-transaction state — no half-applied updates:
try {
const total = store.$transaction(() => {
store.balance -= amount; // debit
store.history.push({ amount }); // log
if (store.balance < 0) {
throw new Error('Insufficient funds'); // 👈 triggers full rollback
}
return store.balance;
});
console.log('New balance:', total);
} catch (err) {
// store.balance and store.history are exactly as before the transaction
}Notes
- On success: all writes commit as one re-render and one undo step (when
{ history: true }). - On failure: the original error is re-thrown after rollback; subscribers see the restored state.
- Returns whatever the callback returns, so you can compute a value inside the transaction.
- Nested transactions are supported — an inner rollback won't undo the outer one.
Vanilla Subscriptions (outside React)
createStore returns the live store alongside useStore, so you can read and react to state anywhere — outside components, in plain modules, loggers, WebSocket/IndexedDB bridges, or tests.
const { useStore, store } = createStore<State, Methods>(
{ count: 0, user: { name: 'Ada' }, items: [] as number[] },
{
increment: (s) => () => { s.count++; },
rename: (s) => (name: string) => { s.user.name = name; },
add: (s) => (n: number) => { s.items.push(n); },
}
);
// 1. Plain, non-reactive deep snapshot (state keys only — no methods/symbols)
const snapshot = store.$getState(); // { count: 0, user: { name: 'Ada' }, items: [] }
// 2. Subscribe to ANY change — receives next + previous snapshots
const unsubscribe = store.$subscribe((next, prev) => {
console.log('changed:', prev.count, '→', next.count);
});
// 3. Subscribe to a derived slice — fires only when it actually changes
const stop = store.$subscribeWithSelector(
(s) => s.user.name,
(name, prevName) => console.log(`name: ${prevName} → ${name}`),
// optional equalityFn (defaults to Object.is)
);
store.increment(); // $subscribe fires; selector (name) does NOT
store.rename('Grace'); // both fire
unsubscribe();
stop();Notes
$getState()returns a deep clone read through the reactive layer, so nested mutations are always reflected.- Subscriptions are batch-aware: inside
batch(...)listeners fire once per flush. $subscribeWithSelectorskips notifications when the selected value is unchanged perequalityFn.- Both subscribe methods return an unsubscribe function.
Versioned Persistence & Migrations
Schema evolution without losing user data:
createStore(
{ user: { name: '', email: '', role: 'guest' } },
{},
{
enabled: true,
key: 'my-app',
version: 2,
migrate: (persisted, fromVersion) => {
if (fromVersion < 2) {
// v1 had `user.username`, rename to `user.name`
const u = (persisted.user ?? {}) as Record<string, unknown>;
if (u.username && !u.name) u.name = u.username;
delete u.username;
}
return persisted;
},
}
);Stored payloads are wrapped in a small envelope { __hs_v, __hs_d }. Payloads without this envelope are treated as legacy version: 0 and fed through migrate on load.
Deep-Merge Hydration (default: on)
When you add a new nested field to initial state, older persisted payloads no longer erase it — nested plain objects are deep-merged with the initial shape. Disable with deepMerge: false for a strict replace.
$reset()
Return the store to its initial state and clear any persisted payload:
store.$reset();Handy for logout flows, multi-tenant switches, and test teardown.
Performance
H-State v2.0 is optimized for production use:
Automatic Optimizations
- Shallow Comparison: Skips updates when values haven't changed
- WeakMap Caching: Reactive wrappers cached to avoid recreation
- Batch-Aware Updates: All methods automatically batched
- Signal-Based: Efficient UID tracking instead of expensive diffing
Benchmarks
Compared to other state management libraries:
| Operation | H-State v2 | Zustand | Context API | |-----------|-----------|---------|-------------| | Small Array Add (1k) | ~2.8ms | ~2.5ms | ~0.5ms | | Medium Array Add (5k) | ~16.7ms | ~16.8ms | ~2.6ms | | Large Array Add (10k) | ~44.5ms | ~45.1ms | ~4.9ms | | Object Shallow (10k) | ~3.7ms | ~4.2ms | ~5.5ms | | Deep Nested (10k) | ~4.2ms | ~6.5ms | ~4.4ms | | Counter (100k) | ~31.8ms | ~34.3ms | ~36.3ms |
Note: Context API is faster for simple operations but doesn't scale well for complex state management.
Best Practices
// ✅ Good - Use batch for multiple updates
batch(() => {
store.user.name = 'John';
store.user.age = 25;
store.user.email = '[email protected]';
});
// ✅ Good - Direct nested updates
store.settings.theme = 'dark';
// ✅ Good - Use $merge for multiple properties
store.$merge({ count: 5, status: 'active' });
// ❌ Avoid - Multiple separate updates without batch
store.name = 'John'; // Re-render 1
store.age = 25; // Re-render 2
store.email = 'x'; // Re-render 3Migration Guide
From v1.x to v2.x
V2.x maintains backward compatibility but adds powerful new features:
// v1.x - Still works!
store.user = { ...store.user, name: 'John' };
// v2.0+ - Deep reactivity
store.user.name = 'John'; // Just works! ✨
// v2.0+ - Batch updates
batch(() => {
store.count = 5;
store.name = 'John';
});
// v2.1+ - Persistence
const { useStore } = createStore(
{ count: 0 },
{},
{ enabled: true } // New optional 3rd parameter!
);Upgrading to v2.1.0
No breaking changes! Just install the latest version:
npm install h-state@latestNew in v2.1:
- ✅ Optional 3rd parameter for persistence
- ✅
$persist()and$clearPersist()methods - ✅ All existing code continues to work
Example migration:
// Before (v2.0)
const { useStore } = createStore(
{ todos: [] },
{ addTodo: (store) => (todo) => {
store.todos = [...store.todos, todo];
}}
);
// After (v2.1) - Add persistence!
const { useStore } = createStore(
{ todos: [] },
{ addTodo: (store) => (todo) => {
store.todos = [...store.todos, todo];
}},
{ enabled: true, key: 'my-todos' } // ← Just add this!
);Links
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
