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
signal vs store)store · snapshot · subscribe · markRaw · isStore / unwrapuseViewModel, repeat, computed/effectstore() deliberately does not doMost sections serve both end users and contributors. A handful of internal-mechanism notes are called out inline.
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
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:
signal(x) at the call site means “shallow, reference-based.”store(x) at the call site means “deep, path-granular.”A reader can predict behavior from the import line alone.
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:
obj.a.b = c” → store().signal.set(newValue)” → signal().signal(). (Wrapping a number in a store is technically allowed but pointless; the primitive itself isn’t proxiable.)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;
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]
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
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-scopedsubscribe(state, "some.path", cb)or aneffect()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, andprototypeare blocked — they resolve toundefinedrather 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 internalisStore/markRawflags areSymbols, so untrusted JSON (which can only produce string keys) cannot spoof store identity or escape reactivity.
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.
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);
}
This is the load-bearing contract. Implementations and tests will assert it exactly.
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)
| 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 |
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
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).
Arrays are first-class. Three subtleties to call out:
length. arr[3] = x notifies subscribers to arr[3] only, not to arr.length or arr[2].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.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.
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:
isStore(s) can narrow.store(plainObject) and plainObject are not interchangeable in code that explicitly demands one — APIs can opt into requiring stores.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.
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.
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:
delete state.k, array truncation via arr.length = N): the key’s SignalNode is dropped from the map immediately after its subscribers are notified. Pending subscribers re-run and re-subscribe to a fresh node if they still read the path; the orphaned node is collected. This is what keeps stores with churning keys (caches, dynamic maps) from growing their node map without bound. (An active subscriber that keeps reading a deleted key legitimately re-creates the node — that’s a live dependency, not a leak.)state.user = newObj): no explicit cleanup needed. The old raw object carries its SignalNode map on its own symbol property, so the entire old subtree’s metadata is garbage-collected together with the now-unreachable old object.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.
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.
html templatesconst 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.
useViewModeluseViewModel 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>
`;
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
computed, effect, batch, untrackedZero 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.
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:
setState("user", "name", "Bob")) is more explicit at the call site and lets you express conditional and functional updates in a single call. We deliberately chose mutable assignment for DX reasons (less ceremony for the 90% case), but recognize the readability tradeoff. Users who want the explicit form can write their own helper.Map/Set. Deferred — adding them is a real cost in bundle size and complexity that we don’t think pays rent for the typical embeddable-widget use case. Revisit in v1.3+ if demand is real.action boundaries for clearer transactional semantics. Our answer is batch(), which is functionally equivalent but less semantically opinionated.Failure modes we know exist and how to think about them.
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.
for...in orderIteration tracks the key set, not the order. Adding a key mid-iteration is undefined behavior (same as native JS).
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.
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.
unwrap mutations don’t notifyconst 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.
store() deliberately does not doScope guard — features considered and deliberately left out of v1.1:
Map/Set. Real complexity for narrow benefit in the embeddable-widget use case. Defer to v1.3 if demand is real.snapshot() is the primitive; building undo on top is a 30-line userland helper.produce(state, draft => ...). batch(() => { ... }) around direct mutations covers the same need without the bundle cost of structural sharing.computed() reading from multiple stores.snapshot() is the building block; SSR hydration is a downstream library, not a primitive concern.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.
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.
SchemaProp shape that path-binding proxies conform to.