Store

Proxy an object into a tree of writable signals, one per property. Extend a store as a scoped overlay, fork it for a throwaway draft, and read a diff of every change as an operation log.

@mmstack/primitivesnpm

#store and mutableStore

store proxies an object (or a signal of an object) into a tree of WritableSignals, one per property, created lazily and cached. A deeply nested field stays independently reactive, and writing through it flows back into the source. Arrays expose their indices as signals plus a reactive .length signal and Symbol.iterator.

import { store } from '@mmstack/primitives';

const state = store({
  user: { name: 'Alice', address: { city: 'NYC', zip: 10001 } },
  tags: ['admin', 'editor'],
});

state.user.address.city();        // read: 'NYC' & trigger only on .city() changes
state.user.address.zip.set(90210); // two-way write into the source
state.tags[0]();                  // 'admin'
state.tags.length();              // 2, reactive

mutableStore is the same, backed by a MutableSignal. Mutability propagates, so every child gets mutate and inline too.

import { mutableStore } from '@mmstack/primitives';

const settings = mutableStore({ notifications: { email: true } });

settings.notifications.mutate((n) => {
  n.email = false;
});

store and mutableStore take a plain value and create the source for you. When you already own the signal, reach for toStore, the factory both are built on: it proxies an existing Signal (or WritableSignal / MutableSignal) and preserves its writability, so a store layered over a signal you own writes back through it.

import { signal } from '@angular/core';
import { toStore } from '@mmstack/primitives';

const source = signal({ user: { name: 'Alice' } });

const state = toStore(source); // proxies the signal you already own
state.user.name.set('Bob');    // writes back through source
source().user.name;            // 'Bob'

A write through a null or undefined path is dropped by default. Pass vivify to create the missing intermediate containers instead: 'auto' (an array for index keys, an object otherwise), 'object', 'array', or a () => container factory.

import { store } from '@mmstack/primitives';

const form = store(
  { user: null as { address?: { city: string } } | null },
  { vivify: 'auto' },
);

form.user.address.city.set('NYC');
// form() is now { user: { address: { city: 'NYC' } } }

Unions are supported by default & throughout the graph. A node can flip between array, record, primitive, and null, and a child signal you grabbed before the flip stays correct after it. If you can promise a node never flips between a leaf and a sub-store, pass { noUnionLeaves: true } to resolve each node's leaf-ness once instead of keeping it reactive. Leave it off if a value can switch between a primitive and an object or array.

The names set, update, mutate, inline, and asReadonly resolve to the signal's own methods, so record keys with those names are not reachable as child stores. Read them off the value (s().set) instead.

Edit nested state, no spread

Demo loads on scroll.

#opaque, an indivisible leaf

By default the store proxies into every plain object it finds. When a value is a self-contained blob you never address by path (a parsed config, a third-party object, a class instance you keep whole) that is wasted machinery. opaque(value) marks it so the store treats it as a single leaf, the same way it already treats a Date or a RegExp: it comes back whole from a read, and you replace it with a set rather than reaching into its keys.

import { opaque, store } from '@mmstack/primitives';

const state = store({
  editorConfig: opaque({ theme: 'dark', plugins: { fold: true } }),
});

state.editorConfig();                             // the whole object, not a child store
state.editorConfig.set(opaque({ theme: 'light', plugins: {} }));

The marker is a non-enumerable symbol, so it never shows up in spreads or iteration, and the call is idempotent. Mark before you freeze, since the marker is written with defineProperty. isOpaque(value) is the matching guard, for niche interop.

#extendStore

extendStore(store, seed) creates a scoped overlay: a child store that shares the parent's signals for inherited keys (the same WritableSignal, so writes go through and parent changes flow down) while keeping the seed and any new keys in a local layer that never propagates upward.

import { extendStore, store } from '@mmstack/primitives';

const app = store({ user: { name: 'Alice' }, theme: 'dark' });

const scope = extendStore(app, { draft: '' }); // inherits user + theme, adds a local draft

scope.user === app.user;    // true, the same signal (shared, two-way)
scope.user.name.set('Bob'); // writes through to the parent
scope.draft.set('hello');   // local only, app never gains 'draft'
scope();                    // { user: { name: 'Bob' }, theme: 'dark', draft: 'hello' }

Resolution per key is local, then parent, then local. A seed key (or one set on the scope before it exists on the parent) is local and shadows the parent, and keeps shadowing even if the parent later grows that key. A parent-only key writes through. A brand-new key lands locally. The scope inherits the parent's vivify and noUnionLeaves config, so extendStore does not take those options. It composes: extendStore(extendStore(app, x), y) chains parents.

The seed can also be a signal of the matching kind, so an existing, externally owned signal becomes the local layer.

#forkStore

forkStore(base) creates an isolated, writable overlay on a base store. Writes stay local to the fork, so the base is untouched; paths the fork has not edited read through to the base. commit() flushes the fork's value onto the base, and discard() drops the staged writes. Use it for drafts, edit-and-cancel dialogs, and optimistic branches.

import { forkStore, store } from '@mmstack/primitives';

const base = store({ user: { name: 'Alice', age: 30 }, theme: 'dark' });

const draft = forkStore(base);
draft.store.user.name.set('Bob'); // local only, base still reads 'Alice'
base.user.name();                  // 'Alice'

draft.commit();     // flush the edits onto the base
base.user.name();   // 'Bob'
// draft.discard(); // or throw the edits away

The fork is a full store: deep reads and writes, extendStore, everything store gives you, all under draft.store. It is built on linkedSignal, so it holds local writes until the base changes underneath it, then runs a strategy to reconcile.

strategyBehavior
'fine' Per-path 3-way merge. Keep the paths the fork edited, take the base's live values for the rest. Default for an immutable base. Unsupported on a mutable base (it falls back to 'coarse').
'coarse' Any base change resets the whole fork. Cheapest, and correct when the base is held for the fork's lifetime. Default for a mutable base.
ReconcileFn<T>(ancestor, mine, theirs) => merged, for a bring-your-own merge (array-by-id, Immer patches, CRDT-ish).

The fork inherits the base's vivify and noUnionLeaves automatically. Pass them explicitly only to override.

The 3-way merge behind 'fine' is exported as merge3(ancestor, mine, theirs), so a bring-your-own ReconcileFn can lean on it and only special-case the paths it cares about. It short-circuits untouched subtrees by reference identity (structural sharing keeps their references stable), so it deep-walks only the paths both sides changed. That same contract is why it needs the copy-on-write reference stability a store gives it.

#projection, a derived store

projection is the store-shaped counterpart to computed. Where a computed derives one value, projection derives a whole store subtree: the function receives a mutable draft seeded with the current value, mutates it in place or returns new data, and the result is reconciled against the previous value by identity. Unchanged object subtrees keep their reference, and array items matched by key keep their identity across a reorder, insert, or remove.

import { projection } from '@mmstack/primitives';

const users = signal<User[]>([...]);

// a read-only store derived from a computation, reconciled by 'id'
const active = projection<User[]>(
  () => users().filter((u) => u.active),
  [],
  { key: 'id' },
);

active[0].name(); // per-leaf reads, like any store

// the draft form: mutate in place instead of returning
const summary = projection<{ total: number; active: number }>(
  (draft) => {
    draft.total = users().length;
    draft.active = users().filter((u) => u.active).length;
  },
  { total: 0, active: 0 },
);

That reconciliation is what makes reads through the result fine-grained. The whole projection recomputes on the first read after a signal it depends on changes, exactly like a computed (memoized, lazy, coherent right after a write), but a computed over one field only recomputes when that field actually changed, because everything else kept its reference. The function must be pure, it runs inside the reactive computation. Prefer computed for a plain value; reach for projection when a derivation should be read like a store.

The reconciler is exported standalone as reconcile(prev, next, key), for producing a reference-stable value by hand. Values must be structured-clonable, since the draft is a clone of the current value.

#opLog

opLog is an operation log over any object-shaped WritableSignal that honors the copy-on-write contract. Stores qualify, and so do plainly immutably-updated model signals. Each tick's changes are recovered as one batch of path-level set and delete ops by a reference-identity-pruned diff, from outside the signal, at a cost of O(changed paths) and zero when no log exists.

import { opLog, store } from '@mmstack/primitives';

const state = store({ user: { name: 'Ann' }, items: [1, 2] });
const log = opLog(state);

log.subscribe((batch) => send(batch)); // lossless, ordered
log.latest();                          // Signal<OpBatch | null>, lossy sampling

state.user.name.set('Bea');
// batch: { origin, version, ops: [{ kind: 'set', path: ['user','name'], next: 'Bea', prev: 'Ann' }] }

subscribe is ordered and lossless, the feed for sync and persistence. latest is a Signal<OpBatch | null>, a lossy sampling view for devtools. apply applies a batch in one commit and advances the diff baseline in the same step, so applying a remote batch emits no echo. Sync loops terminate by construction.

import { invertBatch } from '@mmstack/primitives';

log.apply(remoteBatch);   // one commit, advances the baseline, emits no echo
invertBatch(batch);       // prev-based inverse, undo is a data transform

invertBatch(batch) returns a prev-based inverse, so undo is a data transform. Batching is per tick (two writes to one leaf in a tick compose into one op), and a forkStore's commit() lands as a single batch. Mutable stores are unsupported here, since in-place mutation defeats the reference-identity diff.