Skip to content

Stores

Create an Immer-backed store and mutate it through drafts.

@nice-code/state is a small state store that works with any framework (built on Immer). It lets components subscribe to just the slice of state they care about, derive state from other state, stream changes as patches, and — if you use React — comes with a React adapter.

Terminal window
bun add @nice-code/state immer
  • One store, updated the easy way — write normal mutations (s.count += 1) and Immer turns them into a new immutable state for you, reusing the parts that didn’t change.
  • Updates that change nothing cost nothing — subscribers are only notified when the state actually changes.
  • Subscribe to a slice, not the whole thing — a component or side-effect only re-runs when the specific slice it watches changes.
  • Plays nicely with React — built on useSyncExternalStore, with no provider or context to set up.

Pass an initial value, or a function that builds one (handy for SSR and resetting).

import { Store } from "@nice-code/state";
interface ICounterState {
count: number;
step: number;
}
export const counterStore = new Store<ICounterState>({ count: 0, step: 1 });

Updates run through Immer — change the draft however you like, and the store saves a new immutable state from it.

// Single update
counterStore.update((s) => {
s.count += s.step;
});
// Batched — applied in order, committed as one change
counterStore.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 state
counterStore.replace({ count: 0, step: 1 });
// Replace by mapping from current state
counterStore.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. The pipeline is mutate-only, so the natural reset idiom would be a silent no-op:

// ❌ 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 state wholesale, say so — that is what replace is for:

// ✅
counterStore.replace(createInitialState());

Returning the draft itself is always fine. It is redundant, not wrong — 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));
const { count } = counterStore.state;

The state getter returns the current state, frozen for read safety — writes must go through update/replace.

Only read store.state in plain logic. In components use useStoreState; for side-effects use watch.

On the server, useStoreState just reads the store’s current value (it doesn’t subscribe). Build your stores with a function (new Store(() => initialState())) so each request can start with its own fresh state.