@nice-code/state
v0.67.0
Published
Readme
@nice-code/state
Docs: nicecode.io — guides, integrations, and the full API surface. Working with an AI assistant? Point it at nicecode.io/llms-state.txt (just this package) or nicecode.io/llms.txt (the whole stack) — the complete, current docs flattened into plain text.
Framework-agnostic, Immer-backed state container with fine-grained selector subscriptions, derived reactions, patch streams, and a drop-in React adapter + devtools panel.
Install
bun add @nice-code/state immerreact is an optional peer dependency (>=19) — only needed for the @nice-code/state/react adapter.
Why
- One immutable store, mutated with Immer — write plain mutations (
s.count += 1), get structural sharing for free. - No-op updates are genuinely free — listeners only fire when the root reference actually changes.
- Fine-grained selectors — components and side-effects re-run only when their watched slice changes structurally, not on every update.
- Tear-free React — the adapter is built on
useSyncExternalStore(React 18+ concurrent-safe), no provider/context required. - Patches + devtools — every mutation can emit Immer patches; the optional devtools panel gives you a timeline, state inspector, diff view, and revert.
Core concepts
- Store — owns one immutable state value and a set of listeners.
- update — mutate the state through an Immer draft.
- watch — subscribe to a derived slice; fires only on structural change (outside React).
- createReaction — derive further state from the store's own mutations.
- useStoreState — subscribe a React component to a store or a selected slice.
Create a store
import { Store } from "@nice-code/state";
interface ICounterState {
count: number;
step: number;
}
// Pass a value, or a factory function (re-used by SSR / reset).
export const counterStore = new Store<ICounterState>({ count: 0, step: 1 });Update state
Mutations run through Immer — mutate the draft freely, the store commits a new immutable state.
// Single update
counterStore.update((s) => {
s.count += s.step;
});
// Batched — an array of updaters applied in order, committed as one change
counterStore.update([
(s) => { s.count += 1; },
(s) => { s.count += 1; },
]);
// Replace the whole state
counterStore.replace({ count: 0, step: 1 });
// Replace by mapping from the current state
counterStore.replaceFromCurrent((s) => ({ ...s, count: 0 }));The second argument to an updater is a readonly snapshot of the pre-update state:
counterStore.update((draft, original) => {
draft.count = original.count * 2;
});Read state
const { count } = counterStore.state;
store.stateis the current state, frozen for read safety — reach for it only in plain logic. In components useuseStoreState; for side-effects usewatch.
React adapter
Import from @nice-code/state/react. No provider needed — pass the store directly.
import { useStoreState } from "@nice-code/state/react";
function Counter() {
// Select a slice — re-renders only when `count` changes.
const count = useStoreState(counterStore, (s) => s.count);
return (
<button onClick={() => counterStore.update((s) => { s.count += 1; })}>
{count}
</button>
);
}// No selector → subscribe to the whole state.
const state = useStoreState(counterStore);Selecting derived/inline values
Selection checks by reference (===) first — perfect for state branches, thanks to Immer's structural sharing. When the reference differs it falls through to an equality function that defaults to deepEqual, so a selector that builds a new object/array each call doesn't re-render on an equal-but-new result:
import { useStoreState } from "@nice-code/state/react";
// New array each call, but only re-renders when the filtered result truly changes.
const activeTodos = useStoreState(store, (s) => s.todos.filter((t) => !t.done));The deep check only runs on a genuine reference change and is bounded by the selected slice; pass a strict comparator ((a, b) => a === b) to opt back out for a very large slice on a hot path.
Component-local stores
useLocalStore creates a store scoped to a component instance, persisted across renders. Pass a deps array to re-create it (with fresh initial state) when dependencies change.
import { useLocalStore, useStoreState } from "@nice-code/state/react";
function Editor({ docId }: { docId: string }) {
const store = useLocalStore(() => ({ draft: "" }), [docId]);
const draft = useStoreState(store, (s) => s.draft);
// ...
}Render-prop binding
InjectStoreState subscribes inline without writing a hook:
import { InjectStoreState } from "@nice-code/state/react";
<InjectStoreState store={counterStore} on={(s) => s.count}>
{(count) => <span>{count}</span>}
</InjectStoreState>Reactions — derive state from state
A reaction watches a slice and, when it changes, runs a recipe inside Immer to derive further state on the same store. Reactions run before subscribers are notified, so derived fields are always consistent in a single render.
interface IState {
count: number;
doubled: number; // derived — never written by hand
history: number[]; // derived
}
const store = new Store<IState>({ count: 0, doubled: 0, history: [] });
store.createReaction(
(s) => s.count, // watch
(count, draft) => { // derive
draft.doubled = count * 2;
draft.history = [...draft.history, count].slice(-12);
},
{ runNow: true }, // run once immediately to seed derived state
);createReaction returns a disposer to remove the reaction.
Watch — side-effects outside React
watch subscribes to a derived slice and fires the listener only when it changes structurally. Ideal for logging, persistence, or syncing to non-React code.
const unsubscribe = store.watch(
(s) => s.count,
(count, allState, previousCount) => {
console.log(`count: ${previousCount} → ${count}`);
},
);The low-level store.subscribe(() => { ... }) fires on every committed update (no slice, no args) — this is the primitive the React adapter builds on.
Patches
Every update can emit Immer patches, enabling undo/redo, sync, and the devtools. Patches are only computed when there's a consumer, so the common path stays allocation-free.
import { applyPatchesToStore } from "@nice-code/state";
// Listen to patches for every update
const stop = store.listenToPatches((patches, inversePatches) => {
sendToServer(patches);
});
// Or collect patches for a single update
store.update((s) => { s.count += 1; }, (patches, inverse) => {
undoStack.push(inverse);
});
// Apply patches (e.g. received from elsewhere, or to undo)
store.applyPatches(patches);Devtools
A zero-config inspector for your stores — timeline of changes, before/after diffs, live state inspector with direct editing, and patch-based revert. Import the panel from @nice-code/state/devtools/browser.
// devtools.ts — register the stores you want to observe
import { StateDevtoolsCore } from "@nice-code/state/devtools/browser";
import { counterStore } from "./counterStore";
export const stateDevtools = new StateDevtoolsCore();
stateDevtools.registerStore("counterStore", counterStore);// Stream the core to the devtools window; nothing renders inside your app.
import { stateBridgeScope } from "@nice-code/state/devtools/browser";
import { stateDevtools } from "./devtools";
host.contribute(stateBridgeScope(stateDevtools));The panel — rendered in the standalone devtools window — lets you filter by store, inspect/edit live state, view per-change diffs and patches, pause the timeline, and revert a change via its inverse patches.
Registering a store attaches a patch listener (a negligible dev-time cost). Keep devtools setup out of production bundles.
SSR
useStoreState is built on useSyncExternalStore with a server snapshot, so it renders the store's current value on the server without subscribing. Construct stores with a factory (new Store(() => initialState())) so each request can seed its own state.
Exports
| Entry | Contents |
| --- | --- |
| @nice-code/state | Store, update, applyPatchesToStore, and all core types |
| @nice-code/state/react | useStoreState, useLocalStore, InjectStoreState |
| @nice-code/state/devtools/browser | StateDevtoolsCore, NiceStateDevtools |
