dev-react-microstore
v8.1.0
Published
A minimal global state manager for React with fine-grained subscriptions.
Maintainers
Readme
dev-react-microstore
Probably the fastest store library ever created for React.
A minimal, zero-dependency global state manager with fine-grained subscriptions, full TypeScript inference, and a tiny footprint (~2.6KB gzipped).
Benchmarks
Update throughput vs the latest Zustand, Jotai, Valtio, and nanostores — vanilla store engine, ops/sec, higher is faster:
| operation | microstore | zustand | nanostores | valtio | jotai |
|---|---|---|---|---|---|
| update a value + notify | 27.7M | 15.6M | 13.0M | 3.6M | 1.6M |
| functional update (x => x + 1) | 27.8M | 15.5M | 12.9M | 3.4M | 1.4M |
| fine-grained update (1 of 1000) | 27.8M | 14.8M † | 13.0M | 39K ‡ | 1.6M |
| notify 1000 subscribers | 182K | 111K | 56K | — | 35K |
microstore leads the update path: it mutates state in place and notifies with a tight loop, while Zustand allocates a fresh state object per update, Jotai carries atom-graph overhead, and Valtio pays proxy cost. Run npm run bench to reproduce (src/vs.bench.ts). These are store-engine numbers; in React, all five re-render only the components whose data actually changed.
On store creation and subscribe/unsubscribe microstore is mid-pack rather than first — the update path is where it leads. † Zustand's fine-grained row uses separate stores; its single-store selector pattern is O(N). ‡ Valtio batches notifications asynchronously and its per-key subscribe is O(N).
Installation
npm install dev-react-microstoreQuick Start
import { createStoreState, createSelectorHook } from 'dev-react-microstore';
const store = createStoreState({
count: 0,
user: { name: 'Alice', age: 30 },
});
export const useStore = createSelectorHook(store);
function Counter() {
const { count } = useStore(['count']);
return <button onClick={() => store.increment('count')}>{count}</button>;
}One line to create the hook, with all types inferred from the store instance and no manual generics.
API
createStoreState(initialState, options?)
Creates a reactive store. options.equals registers per-key equality up front (the declarative form of skipSetWhen). Returns a StoreType<T>:
| Method | Description |
|--------|-------------|
| get() | Returns the full state object |
| getKey(key) | Returns the value of a single key |
| set(partial) | Partially updates state |
| setKey(key, value) | Sets a single key |
| update(key, updater) | Sets a key from its current value: update(key, prev => next) |
| increment(key, amount?) | Adds amount (default 1) to a numeric key |
| toggle(key) | Flips a boolean key |
| merge(key, partial) | Returns { ...state[key], ...partial } without writing |
| mergeSet(key, partial) | Shallow-merges and writes to the store |
| reset(keys?) | Resets to initial values. No args = full reset |
| batch(fn) | Groups updates so listeners fire once at the end |
| subscribe(keys, listener) | Subscribe to specific keys. Returns unsubscribe function |
| select(keys) | Returns a snapshot of specific keys |
| onChange(keys, callback) | Non-React listener with (newValues, prevValues) |
| derive(outKey, inputs, compute) | Compute a key from other keys, recomputed in the same set that changes an input |
| addMiddleware(fn, keys?) | Intercept, block, or transform updates |
| skipSetWhen(key, fn) | Custom equality: skips the update when fn(prev, next) returns true |
| removeSkipSetWhen(key) | Remove custom equality for a key |
createSelectorHook(store)
Returns a pre-bound React hook with full type inference:
const useStore = createSelectorHook(store);
const { user } = useStore(['user']);
// user is { name: string; age: number }useStoreSelector(store, selector)
Low-level React hook. Prefer createSelectorHook for cleaner usage. The selector is an array of keys, optionally with per-key equality functions:
// Key array — subscribes to those keys, returns a { key: value } slice
const { count, name } = useStoreSelector(store, ['count', 'name']);
// Per-key custom equality — re-render only when your comparison reports a change
const { tasks } = useStoreSelector(store, [
{ tasks: (prev, next) => prev.length === next.length },
]);useStoreKey(store, key)
Subscribes to a single key and returns its value directly, with no wrapper object. It's the lean path for the common one-key case, and re-renders only when that key changes (honoring options.equals / skipSetWhen).
const count = useStoreKey(store, 'count');StoreType<T>
The type of a store instance. Import it to hold a store as a field (compose stores) or pass one around:
import { createStoreState, type StoreType } from 'dev-react-microstore';
interface Row { store: StoreType<RowState>; /* … actions, refs … */ }createStore(initialState, defineActions, options?)
A store with its actions bundled on .actions, so the functions that mutate state live with the store instead of scattered module setters. Everything on StoreType<T> (get, set, subscribe, …) is still available.
const counter = createStore({ count: 0 }, (store) => ({
inc: () => store.set({ count: store.get().count + 1 }),
reset: () => store.reset(['count']),
}));
counter.actions.inc();
const { count } = useStoreSelector(counter, ['count']);createStoreFamily(init, options?): stores in stores
A keyed collection of stores for per-item state (grid rows, per-entity slices). Each id gets its own store with its own fine-grained subscriptions, so updating one item re-renders only that item. get is a pure, memoized, render-safe accessor.
const rows = createStoreFamily((id: string) => ({ selected: false, label: '' }));
// mutate one item
rows.get('a').set({ selected: true });
// a row component subscribes to its OWN sub-store — siblings don't re-render
function Row({ id }: { id: string }) {
const { selected } = useStoreSelector(rows.get(id), ['selected']);
return <Cell active={selected} />;
}For a single-key row, useStoreKey(rows.get(id), 'selected') is even leaner: it returns the value directly, with no wrapper object.
options may be static or a per-id function ((id) => StoreOptions). Lifecycle: has(id), remove(id) (a later get starts fresh), keys(), size(), clear(). Keep the list of live ids in your own store (you usually already have it) and read each item through its sub-store.
merge vs mergeSet
merge returns the merged object without touching the store:
const updated = store.merge('user', { age: 31 });
// updated = { name: 'Alice', age: 31 }
// store is unchangedmergeSet writes it:
store.mergeSet('user', { age: 31 });
// store.user is now { name: 'Alice', age: 31 }Both are type-safe. Calling on a primitive key is a compile error.
Batching
Group multiple updates so listeners fire once:
store.batch(() => {
store.setKey('count', 10);
store.mergeSet('user', { age: 25 });
store.setKey('name', 'Bob');
});reset
store.reset(); // Full reset to initial state
store.reset(['count']); // Reset specific keysConvenience setters
update sets a key from its current value; increment and toggle are typed to numeric and boolean keys:
store.update('count', (c) => c + 1); // functional update
store.increment('count'); // += 1
store.increment('count', 5); // += 5 (a negative amount decrements)
store.toggle('open'); // flip a boolean
store.increment('open'); // ✗ compile error — 'open' is a boolean
store.toggle('count'); // ✗ compile error — 'count' is a numberAll three go through setKey, so they respect middleware, equality, and batching.
derive: computed keys
Derive a key from other keys. compute runs synchronously inside any set that changes an input and commits its result in the same update, so components subscribed to the derived key re-render only when its value actually changes:
const store = createStoreState({ first: 'Ada', last: 'Lovelace', full: '' });
store.derive('full', ['first', 'last'], ({ first, last }) => `${first} ${last}`);
store.setKey('last', 'Byron');
store.getKey('full'); // 'Ada Byron'compute receives only the declared inputs. Returns a disposer that removes the derivation.
Middleware
Intercept, block, or transform updates:
// Validation — block negative counts
store.addMiddleware(
(state, update, next) => {
if (update.count !== undefined && update.count < 0) return;
next();
},
['count']
);
// Transform
store.addMiddleware((state, update, next) => {
if (update.user?.name) {
next({ ...update, user: { ...update.user, name: update.user.name.trim() } });
} else {
next();
}
});
// Logging
store.addMiddleware((state, update, next) => {
console.log('Update:', update);
next();
});Persistence
Built-in middleware for automatic state persistence. Supports both sync and async storage:
import { createStoreState, createPersistenceMiddleware, loadPersistedState } from 'dev-react-microstore';
// Sync (localStorage / sessionStorage)
const persisted = loadPersistedState<AppState>(localStorage, 'app', ['theme', 'user']);
const store = createStoreState<AppState>({ theme: 'light', user: null, ...persisted });
store.addMiddleware(createPersistenceMiddleware(localStorage, 'app', ['theme', 'user']));
// Async (React Native AsyncStorage)
const persisted = await loadPersistedState<AppState>(AsyncStorage, 'app', ['theme', 'user']);
const store = createStoreState<AppState>({ theme: 'light', user: null, ...persisted });
store.addMiddleware(createPersistenceMiddleware(AsyncStorage, 'app', ['theme', 'user']));Each key is stored individually (app:theme, app:user).
onChange
Listen for value changes outside React:
const unsub = store.onChange(['theme', 'locale'], (values, prev) => {
document.body.className = values.theme;
});
unsub();Custom Comparison
Control when re-renders happen:
const { tasks } = useStore([
{
tasks: (prev, next) =>
!prev.some((t, i) => t.completed !== next?.[i]?.completed)
}
]);skipSetWhen
Custom equality per key, skipping updates when values are semantically equal:
const store = createStoreState({ user: { id: 1, name: 'Alice' }, tags: ['a', 'b'] });
store.skipSetWhen('user', (prev, next) => prev.id === next.id && prev.name === next.name);
store.skipSetWhen('tags', (prev, next) => prev.length === next.length && prev.every((t, i) => t === next[i]));
store.mergeSet('user', { name: 'Alice' }); // skipped — same content
store.removeSkipSetWhen('user'); // back to reference equalityFeatures
- Fine-grained subscriptions: components only re-render when their keys change
- Full TypeScript inference: no manual generics
createSelectorHookfor one-line per-store hooksuseStoreKeyfor single-key subscriptions that return the value directlymerge/mergeSetfor ergonomic object updatesupdate/increment/toggleconvenience setters, typed to the right key kindsbatchto group updatesresetto restore initial state (full or per-key)derivefor computed keys, recomputed only when their inputs changeonChangefor non-React listenersskipSetWhenfor custom equality- Custom comparison functions in selectors
- Middleware (validation, transforms, logging)
- Persistence (localStorage, sessionStorage, AsyncStorage)
- Zero dependencies (peer: React >= 17)
- ~2.6KB gzipped
ESLint rule
The package ships an ESLint rule, no-unused-selector-keys, that flags selector keys you never destructure, with no separate plugin to install. Requires ESLint 9+ (flat config):
// eslint.config.js
import microstore from 'dev-react-microstore/eslint';
export default [{
plugins: { 'react-microstore': microstore },
rules: { 'react-microstore/no-unused-selector-keys': 'warn' },
}];// warns — 'b' is selected but never destructured
const { a } = useStore(['a', 'b']);
// fine
const { a, b } = useStore(['a', 'b']);Selecting a key that doesn't exist is already a TypeScript error, so this rule covers the one thing types can't: keys you asked for and never used. It handles useStoreSelector, hooks bound via createSelectorHook, and { key: compareFn } custom-compare entries. For selector hooks bound in another file, list their names:
rules: {
'react-microstore/no-unused-selector-keys': ['warn', { hooks: ['useAppStore'] }],
}