Skip to content

Stores

A store is a thin convention layered on top of signals. It earns its keep when:

  • Multiple consumers read the same state.
  • Mutations are non-trivial (multi-step, validating, derived).
  • The state survives across navigation / route changes / sign-out and needs an explicit reset() hook.

The factory:

import { defineStore } from "kerfjs";
const counter = defineStore({
initial: () => ({ count: 0 }),
actions: (set, get) => ({
inc: () => set({ count: get().count + 1 }),
dec: () => set({ count: get().count - 1 }),
}),
});
  1. state is read-only. Consumers read via state.value or subscribe via effect(). They cannot write directly.
  2. actions is the only mutation surface. All writes go through named action functions; tests assert against actions, not against arbitrary writes.
  3. reset() resets to initial(). Always defined. Tests use it for setup; lifecycle hooks (route change, sign-out) use it for tear-down.
counter.state.value.count; // direct read (not auto-tracked unless inside an effect/computed)

Inside an effect() or a mount() render fn, state.value reads ARE tracked — that’s how mount knows to re-render when actions mutate the store.

counter.actions.inc(); // → state.value === { count: 1 }
counter.actions.dec(); // → state.value === { count: 0 }

Actions are plain methods — call them from event handlers, async flows, anywhere.

counter.reset(); // back to { count: 0 }

Per-store reset is useful in tests. There’s also a global hook:

import { resetAllStores } from "kerfjs";
resetAllStores(); // resets EVERY store created via defineStore()

Use cases:

  • Test setup: beforeEach(() => resetAllStores()).
  • App lifecycle: project switch / sign-out / route reset where every piece of state should return to its initial shape.

The registry is module-level. Every defineStore({...}) call appends to it. There’s no opt-out — if you don’t want a store to participate, don’t put it in defineStore(); use a raw signal instead.

Actions are just functions. Anything goes:

const cart = defineStore({
initial: () => ({ items: [] as Item[], pending: false }),
actions: (set, get) => ({
async checkout() {
set({ ...get(), pending: true });
try {
await api.submit(get().items);
set({ items: [], pending: false });
} catch {
set({ ...get(), pending: false });
}
},
}),
});

If you want the writes inside checkout() to be a single notification to subscribers, wrap them in batch():

import { batch } from 'kerfjs';
actions: (set, get) => ({
pay() {
batch(() => {
set({ ...get(), step: 'paying' });
set({ ...get(), receipt: makeReceipt() });
set({ ...get(), step: 'done' });
});
// → consumers re-run once, seeing the final state.
},
}),

Partial-set anti-pattern and the KERF_DEV_WARN_NARROW_SET opt-in warn

Section titled “Partial-set anti-pattern and the KERF_DEV_WARN_NARROW_SET opt-in warn”

set(next) REPLACES the entire state object — it does NOT merge. A partial-set call like set({ filter }) against a 3-key state of {items, filter, editingId} silently wipes items and editingId to undefined. The TypeScript signature catches this (TState is inferred from initial(), so any partial object fails to typecheck), but only if your consumer code is in a strict tsc --noEmit run — projects on partial-TS migrations, with noImplicitAny: false, or with the type assertion as TState in front of a partial literal will slip past the static check.

To catch this at runtime, set the opt-in env var in dev or CI:

Terminal window
KERF_DEV_WARN_NARROW_SET=1 npm run dev

When the diagnostics are installed and this warning is enabled, every defineStore.set(next) call checks whether any key from the current state is missing in next. The hook is resolved at set() call time, so a store created before kerfjs/dev is installed starts checking on its next action after installation. The first violation per store emits a one-shot console.warn naming the missing keys and pointing at the canonical merge fix:

kerf: defineStore.set() called with keys missing from the current state — `items`, `editingId`. set() REPLACES state; the missing keys will be undefined after this call. Use `set({ ...get(), ...next })` to merge instead, or update each call site to pass the full state. Set KERF_DEV_WARN_NARROW_SET=0 (or unset it) to silence this warning.

The warn is off by default because narrow-set IS legal — a reset() that drops keys, a feature-flag-driven schema change, a state shape that genuinely needs to shrink would all warn under this heuristic. Opt-in keeps the diagnostic available without penalizing the legitimate cases. When the dev entry is absent, the hook slot is empty and no warning work runs; when it is installed, the warning’s own switch short-circuits before the key comparison. See Dev-mode warnings for the full dev-warn family and the rules that keep them coherent.

get() is typed as () => Readonly<TState> — a compile-time counterpart to the dev-mode runtime guard. Actions that try to mutate get().count = 42 fail tsc --noEmit before they ever reach the runtime.

This runtime guard is active only when the development diagnostics are installed — import 'kerfjs/dev'. kerf does not infer development mode from NODE_ENV, so without that import get() returns the bare object even in a dev build (and a store audit or test that relies on the throwing get() proxy must install kerfjs/dev first). When the diagnostics are installed, the value returned by get() is wrapped in a deep read-only Proxy at runtime. Any write to it — a top-level assignment, a delete, an Object.defineProperty, or a nested mutation like get().nested.x = 1 — throws a TypeError at the call site rather than landing on the underlying state and slowly desyncing the reactive consumers. The proxy wraps nested plain objects and arrays lazily on access, so the guard is deep at O(1) per property read with no cloning. Reads are transparent: spread ({ ...get() }), JSON.stringify(get()), Object.keys(get()), instanceof, and array iteration all behave exactly as on the raw object. Production returns the bare reference for zero overhead — no proxy is ever constructed, so prod perf and semantics are byte-identical to a plain object.

A store doesn’t need a derived field built into it; derive via computed() next to the store:

import { computed } from "kerfjs";
export const cartTotal = computed(() =>
cart.state.value.items.reduce((sum, i) => sum + i.price, 0),
);

cartTotal.value is auto-tracked exactly like a raw signal. The computed re-runs when (and only when) the items array changes.

import type { Store } from "kerfjs";
function makeWidget(store: Store<{ open: boolean }, { toggle(): void }>) {
// ...
}

Useful when you pass a store as an argument or store it on a class.

Stores across bundles (multi-entry / islands)

Section titled “Stores across bundles (multi-entry / islands)”

A store is its state: defineStore() returns a module-scope singleton holding a signal and its actions. That makes it a singleton within one module graph — which is exactly what you want, until a build splits your app into several.

The constraint. If your build emits a separate bundle per entry point and inlines shared modules into each (an islands setup — e.g. esbuild with several entry points and no shared chunking), the store’s module is duplicated: each bundle ships its own instance, with its own signal. A write in one bundle’s store is invisible to the other’s. Unlike SafeHtml and arraySignal — value types that carry a Symbol.for(...) brand so multiple copies interoperate — a store cannot be reconciled by identity, because two instances are two separate states. The duplication itself is the problem.

The fix: keep it one instance. The right answer for most apps is to stop the duplication at the build. Configure the bundler so the store’s module is shared, not inlined per entry — a shared code-split chunk, or marking it external and injecting it once. Then every entry imports the same store and state is shared for free, with no runtime machinery.

When you truly can’t share the module (genuinely independent bundles on the same page), mirror writes across the copies with a BroadcastChannel (or a storage event), guarding against the echo:

import { defineStore, effect } from "kerfjs";
export const prefs = defineStore({
initial: () => ({ theme: "light" as "light" | "dark" }),
actions: (set, get) => ({
setTheme: (theme: "light" | "dark") => set({ ...get(), theme }),
}),
});
// One channel per logical store, shared by name across bundle copies.
const channel = new BroadcastChannel("prefs");
let applying = false;
// Broadcast local changes…
effect(() => {
const snapshot = prefs.state.value;
if (!applying) channel.postMessage(snapshot);
});
// …and apply remote ones without re-broadcasting (the echo guard).
channel.onmessage = (e) => {
applying = true;
prefs.actions.setTheme(e.data.theme);
applying = false;
};

This is the “shared state doesn’t span the boundary I assumed” problem in general — the same shape shows up any time module-scope state needs to cross a boundary the module graph doesn’t (bundles here; a scoped context or a per-tenant partition elsewhere). The store primitive stays deliberately small; reach for a transport like the above only at the boundary that needs it.