Stores
Create a store built on Immer, update it by mutating a draft, and read its state.
@nice-code/state is a small state store, built on Immer, that works with any framework. Components subscribe to only the slice of state they care about. You can derive state from other state and stream changes as patches, and there is a React adapter if you use React.
bun add @nice-code/state immerWhy use it
Section titled “Why use it”- Plain mutations — write
s.count += 1and Immer turns it into a new immutable state, reusing the parts that didn’t change. - No-op updates are free — subscribers are only notified when the state actually changes.
- Slice subscriptions — a component or side-effect only re-runs when the slice it watches changes.
- React support — built on
useSyncExternalStore, with no provider or context to set up.
Create a store
Section titled “Create a store”Pass an initial value, or a function that builds one. The function form is handy for SSR and for resetting.
import { Store } from "@nice-code/state";
interface ICounterState { count: number; step: number;}
export const counterStore = new Store<ICounterState>({ count: 0, step: 1 });Update state
Section titled “Update state”Updates run through Immer. Change the draft however you like, and the store saves a new immutable state from it.
// Single updatecounterStore.update((s) => { s.count += s.step;});
// Batched — applied in order, committed as one changecounterStore.update([ (s) => { s.count += 1; }, (s) => { s.count += 1; },]);// Subscribers and React components (`useStoreState`) are notified only ONCE, at the end of// the batch — never on the intermediate state — which avoids extra/tearing renders.
// Replace the whole statecounterStore.replace({ count: 0, step: 1 });
// Replace by mapping from current statecounterStore.replaceFromCurrent((s) => ({ ...s, count: 0 }));An updater’s second argument is a read-only snapshot of the state from before this update:
counterStore.update((draft, original) => { draft.count = original.count * 2;});Updaters mutate — they never return the next state
Section titled “Updaters mutate — they never return the next state”Unlike Immer’s produce, an updater’s return value is ignored. Updaters only mutate, so the
natural way to write a reset would silently do nothing:
// ❌ Throws. The draft was never touched, and the returned object goes nowhere.counterStore.update(() => createInitialState());Rather than let that vanish, the store throws:
@nice-code/state: an update function returned a value without modifying its draft.
To swap the whole state, use replace:
// ✅counterStore.replace(createInitialState());Returning the draft itself is always fine. It is redundant, because the mutation already landed when you made it:
// ✅ Object.assign mutated the draft; returning it changes nothing.counterStore.update((draft) => Object.assign(draft, next));
// ✅ Concise arrows that mutate and happen to return the assigned value.counterStore.update((d) => d.count++);counterStore.update((d) => (d.step = 2));Read state
Section titled “Read state”const { count } = counterStore.state;The state getter returns the current state, frozen so it cannot be changed by accident. Writes must go through update/replace.
Only read
store.statein plain logic. In components useuseStoreState; for side-effects usewatch.
On the server, useStoreState reads the store’s current value and doesn’t subscribe. Build your stores with a function (new Store(() => initialState())) so each request can start with its own fresh state.