marjoram

Stores — Deep Reactivity for Marjoram

Status: Shipped in v1.1.0. This is the canonical contract for store(); the implementation is reviewed and hardened against it. For the “why” behind the design, see STORE_RESEARCH_FINDINGS.md; for the build history, STORE_IMPLEMENTATION_PLAN.md.

Quick links: API · signal vs store · granularity · what’s proxied · templates / useViewModel / repeat · gotchas · examples

Contents

  1. Overview
  2. Why a separate primitive
  3. When to use which (signal vs store)
  4. The API surfacestore · snapshot · subscribe · markRaw · isStore / unwrap
  5. Granularity guarantees
  6. Boundary rules — what gets proxied
  7. Arrays
  8. Type system
  9. Lifecycle and cleanup
  10. Integration: templates, useViewModel, repeat, computed/effect
  11. Comparison to peers
  12. Gotchas
  13. What store() deliberately does not do
  14. Worked examples
  15. Cross-references

Most sections serve both end users and contributors. A handful of internal-mechanism notes are called out inline.

1. Overview

store() is a reactivity primitive — a peer to signal(). Where signal() is shallow and reference-based (good for primitives, atoms, references to whole objects), store() is deep and path-granular: you can mutate state.user.address.city = "x" and only the subscribers to user.address.city re-run. No spread/replace ceremony, no .update(fn) wrapper, no virtual DOM, no whole-object invalidation.

Both primitives are first-class. Pick at the import site:

import { signal, store } from "marjoram";

const count = signal(0); // shallow, reference-based
const state = store({ user: { name: "Alice" } }); // deep, path-granular

count.set(5);
state.user.name = "Bob"; // ← just works; subscribers to that path re-run

2. Why a separate primitive

Marjoram’s existing API is built on explicitness at the call site. The $ prefix rule (vm.$name reactive, vm.name value) makes reactivity legible to anyone reading the code. A flag like { deep: true } declared once and acting invisibly everywhere would break that contract — the most consequential reactivity (nested state) would become the least visible.

Two named primitives keeps the principle intact:

A reader can predict behavior from the import line alone.

3. When to use which

Choose based on the shape of the state, not its size:

State shape Use
Primitive (number, string, boolean, etc.) signal()
Reference to a whole opaque object that’s swapped wholesale (DOM node, class instance, Date) signal()
Plain object or array you’ll mutate in place store()
Plain object you treat as immutable (always replaced wholesale) signal() works fine — store() is overkill
Mixed: a viewmodel that holds primitives and nested objects Use signal() for the primitives at top level and store() for the nested branches, or wrap the whole viewmodel in one store() if every leaf is plain-data

Rules of thumb:

4. The API surface

The whole public surface, deliberately small:

// Core
export function store<T extends object>(initial: T): Store<T>;

// Type — structurally T, with a phantom brand for isStore() narrowing.
export type Store<T extends object> = T & { readonly [__brand]: "Store" };

// Inspect & escape hatches
export function snapshot<T extends object>(s: Store<T>): T;

// Typed-path subscribe. Use "" as the path to subscribe to any change.
export function subscribe<T extends object, P extends Path<T> | "">(
  s: Store<T>,
  path: P,
  callback: P extends ""
    ? (newValue: T, oldValue: T) => void
    : (newValue: PathValue<T, P>, oldValue: PathValue<T, P>) => void
): () => void;

export function isStore(value: unknown): value is Store<object>;
export function unwrap<T extends object>(s: Store<T>): T;

// Mark an object as non-reactive even if it's a plain object/array.
// The marked value passes through stores untouched, never proxied.
export function markRaw<T extends object>(value: T): T;

// Path type helpers (zero runtime cost — TypeScript only).
// See docs/STORE_RESEARCH_FINDINGS.md §8 for the exact implementation.
export type Path<T> = /* depth-capped recursive dotted-string union */ string;
export type PathValue<
  T,
  P extends string,
> = /* resolves path to value type */ unknown;

4.1 store(initial)

Wraps a plain object or array in a deeply reactive proxy. Returns a value that is structurally T (you can pass it anywhere T is expected) but is also reactive: reads inside a computed/effect/html template track the exact paths touched, and writes notify only subscribers to the affected paths.

const state = store({
  user: { name: "Alice", age: 30 },
  todos: [{ id: 1, text: "Buy milk", done: false }],
});

state.user.name; // "Alice"
state.user.name = "Bob"; // notifies only subscribers to user.name
state.todos.push({ id: 2, text: "Walk dog", done: false }); // notifies todos.length + todos[1]

4.2 snapshot(s)

Returns a deep plain-object copy of the store at this moment. Use for serialization, structural equality, logging, and tests. Reading snapshot does not track dependencies — it’s a side-effect-free read for use outside reactive contexts. If you read it inside an effect, the effect won’t re-run when the store changes.

JSON.stringify(snapshot(state)); // safe, deep, plain

4.3 subscribe(s, path, callback)

The low-level escape hatch with a typed path filter. Use only when effect() doesn’t fit (e.g., wiring stores to non-reactive subsystems). Callback receives (newValue, oldValue) for the value at path. Pass "" as the path to subscribe to any change anywhere. Returns an unsubscribe function.

Prefer effect() for almost everything — subscribe exists for interop, not idiomatic use.

// Subscribe to a specific path. Compile-time validated:
const offName = subscribe(state, "user.name", (next, prev) => {
  console.log("Name changed:", prev, "", next); // next, prev are typed as string
});

// Subscribe to any change anywhere:
const offAll = subscribe(state, "", (next, prev) => {
  console.log("Store updated"); // next, prev are typed as T (the whole store)
});

// later:
offName();
offAll();

The path string is checked against T at compile time. subscribe(state, "user.nope", cb) is a type error if T['user'] has no nope property. See §8 for path-type details.

Cost of subscribe(state, "", cb): the whole-store form deep-walks the entire reactive tree on every change to produce the snapshot it hands the callback. That’s O(total tree size) work per write while the subscription is active. For large or hot stores, prefer a path-scoped subscribe(state, "some.path", cb) or an effect() that reads only what it needs. The whole-store form is a convenience for small stores and debugging, not a hot path.

Prototype-key safety: path segments and proxy child-access for __proto__, constructor, and prototype are blocked — they resolve to undefined rather than traversing into the prototype chain. Writes to those keys through a store proxy are silently ignored (dev mode warns). This protects against prototype-pollution and prototype-chain exfiltration when stores hold untrusted data. The internal isStore/markRaw flags are Symbols, so untrusted JSON (which can only produce string keys) cannot spoof store identity or escape reactivity.

4.4 markRaw(value)

Marks a plain object or array so that store() will never proxy it. Useful for stashing arbitrary non-reactive payloads (a config blob, a parsed AST, a 3rd-party library’s state) inside a store. Once marked, the object passes through untouched — reads return the raw value, writes to nested keys are invisible to the store.

import { store, markRaw } from "marjoram";

const state = store({
  user: { name: "Alice" },
  parsedAst: markRaw(largeAstFromParser), // ignored by reactivity
});

state.parsedAst.someNode = "x"; // no notification, no overhead
state.user.name = "Bob"; // reactive, granular as always

This is the same idea as Vue’s markRaw — opt out a single value from being made reactive. Combined with the §6 boundary rules (class instances, Date, Map, Set pass through automatically), markRaw covers the rare case where you have a plain object you want to keep outside the proxy tree.

4.5 isStore(value) and unwrap(s)

Type guard and raw-object accessor. unwrap is the same shape as Vue’s toRaw — useful for interop with code that needs the underlying object (libraries that key off identity, DOM diff’ers, etc.). Mutating the unwrapped object does not notify subscribers — it bypasses reactivity. This is intentional and matches the precedent.

if (isStore(value)) {
  const raw = unwrap(value);
  externalLibraryThatExpectsAPlainObject(raw);
}

5. Granularity guarantees

This is the load-bearing contract. Implementations and tests will assert it exactly.

5.1 What reads track

Reading a path inside a reactive context (computed, effect, html) subscribes to exactly that path:

effect(() => {
  // Subscribes to `user.name`. Not `user`. Not the root.
  console.log(state.user.name);
});

state.user.age = 31; // ← does NOT re-run the effect above
state.user.name = "Bob"; // ← re-runs the effect

Reading an object subtree subscribes to that subtree’s identity, not to every descendant:

effect(() => {
  // Subscribes to `user` as a whole — fires only if `user` itself is replaced.
  console.log(state.user);
});

state.user.name = "Bob"; // ← does NOT re-run
state.user = { name: "Carol" }; // ← re-runs

Iteration subscribes to the key set (so iteration is reactive to add/remove, not to value changes you didn’t iterate over):

effect(() => {
  for (const k in state.user) {
    /* uses k */
  }
});

state.user.email = "a@b"; // ← re-runs (new key)
state.user.name = "Bob"; // ← does NOT re-run (key set unchanged)

5.2 What writes notify

Operation Notifies
state.a.b = x (same value per Object.is) nothing (no-op)
state.a.b = x (different value) subscribers to a.b
state.a.b = x (where b is a new key) subscribers to a.b and iteration subscribers on a
delete state.a.b subscribers to a.b and iteration subscribers on a
state.a = newObj (replacing subtree) subscribers to a. Anything reading through a (e.g. an effect that reads a.b.c) re-runs via this parent notification and re-subscribes to the new subtree — so dependents observe the change, including keys that disappeared (they read back undefined). A stale reference captured before replacement (const old = state.a) keeps pointing at the old object by design and is not re-fired.
arr.push(x) subscribers to arr[length] (the new index) and arr.length — coalesced into a single notification round
arr.splice(i, n, ...items) each affected index, plus length, plus iteration — single round
arr[i] = x subscribers to arr[i] only
arr.length = N (truncate) subscribers to each removed index [N, oldLength), plus length, plus iteration
arr.length = N (grow) length and iteration subscribers

5.3 Batching

Multiple writes inside batch(() => { ... }) produce a single notification round at the end, just like signal(). Stores reuse the existing batch machinery — there is no separate “store batch.”

batch(() => {
  state.user.name = "Bob";
  state.user.age = 31;
}); // ← effects re-run once, not twice

6. Boundary rules — what gets proxied

A store proxies only plain objects and arrays. Everything else is stored and returned by reference, unmodified. The boundary is checked once at proxy-creation time per nested value.

Input type Behavior
Plain object (Object.getPrototypeOf(x) === Object.prototype or null) Proxied
Array (Array.isArray(x)) Proxied (see §7 for array specifics)
null, undefined, primitives Stored by value, no proxy
Date Pass through — date.setHours(...) is invisible to the store
Map, Set, WeakMap, WeakSet Pass through — mutations invisible
RegExp, Promise, ArrayBuffer, typed arrays Pass through
Functions Pass through (used as-is; this-binding preserved)
DOM nodes, class instances (anything with a non-Object prototype) Pass through
Frozen objects (Object.isFrozen(x)) Stored as-is, no proxy. Writes throw in strict mode (same as JS native).

Rationale: wrapping a Date because it happens to be an object is the kind of magic that destroys trust. Vue’s markRaw exists because they got this wrong initially. We get it right from day one by being conservative: if the object has any non-Object prototype, it’s user code or a built-in we don’t understand, and we leave it alone.

Consequence for users: if you want reactive Map or Set, you store a plain object/array. Reactive Map/Set variants are explicitly out of scope for v1.1 (see §13).

7. Arrays

Arrays are first-class. Three subtleties to call out:

  1. Indices are tracked independently of length. arr[3] = x notifies subscribers to arr[3] only, not to arr.length or arr[2].
  2. Mutating methods produce a single notification round. arr.push(a, b, c) notifies subscribers to indices length, length+1, length+2, and length itself — but they fire in one batched round, so a single effect re-runs once, not four times. Same for pop, shift, unshift, splice, sort, reverse, fill, copyWithin.
  3. Read methods work via the normal proxy get path. map, filter, forEach, find, reduce, slice, concat, join, includes, indexOf, some, every, findIndex — no special-casing. They iterate, the proxy registers the dependency, done.

repeat() (see README.md) consumes store arrays directly — see §10.3.

8. Type system

Store<T> preserves T structurally through arbitrary depth. There is no unknown, no loss of generic parameters, no need for as casts.

interface User {
  name: string;
  address: { city: string; zip: string };
  tags: string[];
}

const state = store<User>({
  name: "Alice",
  address: { city: "NYC", zip: "10001" },
  tags: ["a"],
});

state.name; // string
state.address.city; // string
state.tags[0]; // string
state.tags.length; // number

state.address.city = "LA"; // ok
state.address = { city: "LA", zip: "90001" }; // ok
state.address = "wrong"; // ts error

The Store<T> brand is a phantom unique symbol property so that:

But: at any read site, the brand is transparent — you write state.user.name not state.user.name.value or anything similar. There is no .value ceremony. This is the headline DX win over Vue’s ref.

9. Lifecycle and cleanup

9.1 Ownership

A store created at module scope lives forever (same as a top-level signal). A store created inside a widget’s model is owned by that widget — it gets disposed when the widget is destroyed. A store created inside an effect or computed body is owned by that scope.

This is the same ownership model that already governs signal and computed in the existing codebase; stores plug into it.

9.2 Per-path SignalNode GC

Internally, each tracked path gets its own SignalNode, held in a per-object map stashed on the raw object via a non-enumerable symbol. Two GC mechanisms keep this bounded:

This matters because long-lived stores with churning data (e.g., a list of 100k items where you replace the entire list every minute) must not leak memory.

9.3 Manual disposal

store() does not return a .dispose() method. Disposal is implicit via the ownership chain: when the owning scope tears down, the store’s subscribers are notified and the path tree is dropped. This matches useViewModel’s existing implicit-cleanup model. Users with exotic lifecycle needs can wrap a store in a custom scope.

10. Integration with the rest of Marjoram

10.1 html templates

const state = store({ user: { name: "Alice" } });

html`<p>Hello, ${state.$user.name}!</p>`;

$-prefix on a store value gives a reactive path-binding proxy that mirrors the store’s shape. Each leaf access returns a SchemaProp-shaped reactive binding. Each intermediate access returns another path-binding proxy.

// All valid:
html`<p>${state.$user.name}</p>`; // leaf
html`<p>${state.$user}</p>`; // whole subtree, re-renders on user identity change
html`<p>${state.$user.address.city}</p>`; // deep leaf
html`<p>${state.$user.compute(u => u.name.toUpperCase())}</p>`; // transform

The path-binding proxy supports the existing SchemaProp methods (compute, observe, value, peek). Naming collisions with data property names (state.$user.compute when user has a compute property) are resolved in favor of the method, and a dev-mode warning fires. This matches Vue’s precedent with .value.

10.2 useViewModel

useViewModel accepts stores as values. No auto-storing of nested plain objects — this is locked by the non-breaking-change rules in STORE_IMPLEMENTATION_PLAN.md. Existing code that passes a nested plain object continues to behave exactly as it does today.

const vm = useViewModel({
  count: 0, // signal — same as today
  user: store({ name: "Alice" }), // store — opt-in
});

vm.count = 5; // works as today
vm.user.name = "Bob"; // deep mutation, granular notification

html`
  <p>Count: ${vm.$count}</p>
  <p>Name: ${vm.$user.name}</p>
`;

10.3 repeat()

Passing a store array to repeat() Just Works. The integration is symmetric with the existing SchemaProp array case — repeat consumes anything with .value + .observe(), and store-array path-bindings provide both.

const vm = useViewModel({
  todos: store([{ id: 1, text: "Buy milk", done: false }]),
});

html`
  <ul>
    ${repeat(
      vm.$todos,
      t => t.id,
      t => html`<li>${t.text}</li>`
    )}
  </ul>
`;

vm.todos.push({ id: 2, text: "Walk dog", done: false });
// repeat reconciles — only the new <li> is created

10.4 computed, effect, batch, untracked

Zero changes required. Stores expose their tracked paths as SignalNodes — the exact data structure these primitives already subscribe to. Reading state.user.name inside a computed registers a dependency on that path. Writing it triggers re-computation. batch coalesces notifications. untracked bypasses subscription.

This is the central architectural win: there is no parallel reactivity system to maintain.

11. Comparison to peers

Honest. Where competitors are better, we say so.

Feature Marjoram store Solid createStore Vue 3 reactive Valtio proxy MobX observable
Deep reactive
Path-level granularity partial
Mutable-feeling API ⚠️ explicit setter
Plain object/array only (no class wrapping) partial (uses markRaw to opt out) needs config
Atomic multi-path updates via batch() ✅ path-setter via batch via batch via action
Map/Set reactive variants ❌ (deferred)
Snapshot to plain object snapshot() unwrap/manual toRaw (shallow) snapshot partial
Devtools custom formatter ✅ (planned)
Type-preserving through depth partial partial
Zero runtime dependencies
Library size (whole lib gzipped) ~5KB target ~7KB ~34KB ~3KB (just store) ~16KB

Where competitors are honestly better:

12. Gotchas

Failure modes we know exist and how to think about them.

12.1 Destructuring loses reactivity

const state = store({ user: { name: "Alice" } });

const { user } = state; // ← user is now a stable proxy reference
user.name = "Bob"; // ✅ still reactive (same proxy)

const { name } = state.user; // ← name is a primitive copy
state.user.name = "Bob"; // ← `name` const is stale, store updates correctly

Rule: destructuring an object pulls out the proxy (still reactive). Destructuring a primitive pulls out the value (snapshot). Same as JavaScript.

12.2 for...in order

Iteration tracks the key set, not the order. Adding a key mid-iteration is undefined behavior (same as native JS).

12.3 Mutating during render

Writing to a store inside a computed or html interpolation creates a cycle. Dev mode warns; prod silently absorbs (the existing batch system prevents infinite re-entry via _running flags). Don’t do it.

12.4 Class instances are opaque

const state = store({ date: new Date() });
state.date.setHours(10); // ← invisible to the store; no effect re-runs
state.date = new Date(); // ← visible; subscribers to `date` re-run

If you need reactivity inside a class instance, hold the primitive fields you care about as separate store keys.

12.5 unwrap mutations don’t notify

const raw = unwrap(state);
raw.user.name = "Bob"; // ← no notification — you bypassed reactivity

If you write through unwrap, you broke the contract on purpose. Re-read through the proxy after.

13. What store() deliberately does not do

Scope guard — features considered and deliberately left out of v1.1:

14. Worked examples

14.1 A nested form

import { createWidget, html, useViewModel, store, when } from "marjoram";

interface FormState {
  user: { name: string; email: string };
  address: { street: string; city: string; zip: string };
  preferences: { newsletter: boolean; theme: "light" | "dark" };
}

createWidget<FormState>({
  target: "#form",
  model: {
    form: store<FormState>({
      user: { name: "", email: "" },
      address: { street: "", city: "", zip: "" },
      preferences: { newsletter: false, theme: "light" },
    }),
    isValid: vm =>
      vm.form.user.email.includes("@") && vm.form.user.name.length > 0,
  },
  render: vm => html`
    <form>
      <input
        ref="name"
        value="${vm.$form.user.name}"
        oninput="${(e: Event) =>
          (vm.form.user.name = (e.target as HTMLInputElement).value)}"
      />
      <input
        ref="email"
        value="${vm.$form.user.email}"
        oninput="${(e: Event) =>
          (vm.form.user.email = (e.target as HTMLInputElement).value)}"
      />
      <button disabled="${vm.$isValid.compute(v => !v)}">Submit</button>
      ${when(
        vm.$isValid,
        () => html`<p>Looks good!</p>`,
        () => html`<p>Fill out name and email.</p>`
      )}
    </form>
  `,
});

The granularity guarantee: typing in the name field re-renders only the name input’s binding and the isValid computed (which the disabled state and when depend on). The email input is untouched.

14.2 A keyed todo list

import { createWidget, html, useViewModel, store, repeat } from "marjoram";

interface Todo {
  id: number;
  text: string;
  done: boolean;
}

createWidget({
  target: "#todos",
  model: {
    todos: store<Todo[]>([]),
    nextId: 1,
  },
  render: vm => html`
    <ul>
      ${repeat(
        vm.$todos,
        t => t.id,
        t => html`
          <li>
            <input
              type="checkbox"
              checked="${t.done}"
              onchange="${(e: Event) => {
                // Find the todo by id and mutate in place — granular update.
                const target = vm.todos.find(x => x.id === t.id);
                if (target)
                  target.done = (e.target as HTMLInputElement).checked;
              }}"
            />
            ${t.text}
          </li>
        `
      )}
    </ul>
  `,
});

// Adding a todo:
vm.todos.push({ id: vm.nextId++, text: "Buy milk", done: false });
// Only the new <li> is created. Existing rows are untouched.

// Toggling done on todo 1:
vm.todos.find(t => t.id === 1).done = true;
// Only that <li>'s checkbox attribute updates.

15. Cross-references