Skip to content

phaze/store

Deeply-reactive proxy over plain objects and arrays — reads track, writes notify, nested data wraps automatically. Opt-in via the subpath import so the sub-3 KB core stays untouched.

  • Headline budget intact. phaze stays sub-3 KB brotli; for typical use phaze/store is compile-time inlined and ships 0 bytes. The runtime kicks in only for advanced cases — details below.
  • Tree-shake-proof opt-in. A subpath can’t be accidentally pulled into apps that don’t use it.
  • Comparable stack today: preact + @preact/signals + deepsignal ≈ 6.5 KB brotli across three coupled package versions. phaze + phaze/store ships in one version under that combined budget.

Phaze Compiler automatically enables what you need from phaze/store. Write idiomatic store code; the compiler picks the cheapest emission per file. Simple literal-shaped stores accessed by field name compile down to inline per-field signals — zero bytes shipped from /store. Advanced patterns (passing the store across file boundaries, dynamic property access, shallow, $) use the runtime fallback transparently. Same reactivity either way.

Rule of thumb: one level of reactivity is free.

  • store({ email: '', count: 0 })1 level deep: top-level fields like form.email, form.count → compile-inlined, 0 B from /store.
  • store({ user: { name: '' } })2+ levels deep: needs reactive form.user.name → needs the runtime, ~400 B.
What the compile output looks like
// Source
import { store } from '@madenowhere/phaze/store'
const form = store({ email: '', name: '' })
form.email = 'x'
// After phaze-compile (production output)
import { signal } from '@madenowhere/phaze'
const _form_email = signal('')
const _form_name = signal('')
const form = {
get email() { return _form_email() },
set email(v) { _form_email.set(v) },
get name() { return _form_name() },
set name(v) { _form_name.set(v) },
}
form.email = 'x' // setter → _form_email.set(v)

Same architectural pattern as phaze/numeric and phaze/match: a compile-time inline for the static case, a runtime fallback for everything else.

Wrap a plain Object or Array in a deeply-reactive proxy. Throws TypeError on Maps, Sets, class instances, and other non-plain values.

import { store } from '@madenowhere/phaze/store'
import { effect } from '@madenowhere/phaze'
const state = store({ user: { name: 'Anna' }, todos: [] })
effect(() => {
console.log(state.user.name) // tracks
})
state.user.name = 'Bob' // notifies
state.todos.push({ done: false }) // tracks length

Mark an object so store() won’t deep-proxy it. Useful for class instances, large opaque data, or anything you want to handle as a single value.

import { store, shallow } from '@madenowhere/phaze/store'
const state = store({
bigBlob: shallow(someLargeObject),
})
state.bigBlob // === someLargeObject (not a proxy)

Extract the underlying Signal<T> for a property. Useful for passing a single field as a primitive signal to a child component or to <For>’s each slot.

import { store, $ } from '@madenowhere/phaze/store'
const state = store({ name: 'Anna' })
const nameSig = $(state, 'name') // Signal<string>
nameSig.set('Bob') // state.name === 'Bob'
  • Property reads auto-subscribe the active reactive computation per-property.
  • Property writes notify subscribers of that property only — siblings stay idle.
  • Array length updates on push / pop / splice / direct index writes.
  • Iteration via for..in, Object.keys, Object.entries, for..of tracks the keyset; new and deleted properties trigger re-runs.
  • Inherited methods like Array.prototype.map are not signal-cached but still track because they read indices via the proxy.
  • Maps and Sets. Throw on store(). Wrap with shallow() to embed them, or model with plain objects/arrays.
  • Class instances. Same — opt out via shallow().
  • Property descriptor changes like Object.defineProperty. Stick to direct assignment.

Use the existing untrack from phaze — no parallel .current() API on stores:

import { untrack } from '@madenowhere/phaze'
import { store } from '@madenowhere/phaze/store'
const state = store({ count: 0 })
effect(() => {
// doesn't subscribe to state.count
const snapshot = untrack(() => state.count)
console.log(snapshot)
})

store(...) looks like one line of incidental setup — but in a list-with-toggles UI (a todo list, a chat thread, an inbox with read/unread flags, any “rows with per-row interactive state”), it’s the load-bearing decision for the per-item interaction UX. Skipping it forces a fallback to one of five less-good shapes. This section walks the architecture I tried before landing on store(t) per item + the canonical <For> pattern, so the next reader doesn’t have to retrace the path.

The example throughout is the canonical TodoList — an array of todos, each with { id, text, done }, rendered into <li>s with a checkbox that toggles done and a delete button. Source: examples/astro-cloudflare/src/components/TodoList.tsx.

AttemptShapeWhy it failed
1. Plain objects + per-item on:click / on:changetodos = s<Todo[]>(initial), handlers via closure over t.idPer-item phaze listeners died on outer <For> re-run (the original architectural bug). Only the most-recently-mounted item’s buttons worked.
2. .map() with phaze() macro{phaze(todos().map(t => <li>…</li>))} — eager rebuildWorks (full rebuild = fresh listeners every time) but loses <For>’s moveBefore state preservation. Inputs blur, animations restart, video playback resets across mutations. React-flavored.
3. <For> + delegation on <ul> + plain objectsOne on:click / on:change on the parent reads data-id from the click targetDelegation handler survives <For> re-runs (lives in component scope, not per-item). But the visual (strikethrough, gray text) was a class={t.done ? '…' : '…'} static string — set once at mount, never reactive. Toggling did nothing visible until reload.
4. <For> + delegation + CSS peer-checked:<input class="peer"> <span class="peer-checked:line-through">Strikethrough driven by the browser’s native :checked IDL property, which moveBefore preserves. No phaze effect involved in the visual. Works for checkbox-driven state but can’t express anything else — priority > 5 → gray text, due_at < now → red border, custom badge content. CSS sibling combinators don’t reach.
5. <For> + delegation + ref-callback effect at component scopeinstallClasses(ul) reads todos() and walks <li> children via querySelector, applies classes manuallyWorked, but it’s gymnastics — DOM-walking from a parent effect to dodge a framework limitation. The right move is to fix the limitation.
6. store(t) per item + the <For> source fixtodos = s<Todo[]>(initial.map(store)) + runWith(null, …) in <For>’s mountItemThe canonical pattern. Per-item closures pass references through handlers; store proxies give granular per-property reactivity; class:line-through={todo.done} re-runs surgically on todo.done = ….

Why both store AND the <For> source fix are needed

Section titled “Why both store AND the <For> source fix are needed”

The two changes do different jobs and don’t substitute for each other:

WithoutWhat breaks
Without the <For> fix (runWith(null) in mountItem — per-item effects parent to the outer For effect)On any array mutation (todos.set(...) from add/remove), outer re-runs → teardown cascades into per-item effects → reused-by-key entries have dead bindings. Listeners stop firing on older items.
Without store(t) (each Todo is a plain object)class:line-through={todo.done} reads a plain boolean at mount time; the effect has no signal deps, never re-runs. Strikethrough is frozen at the initial value.
Both missingThe original “only the most-recently-added item works” empirical disaster.
Both presentThe canonical pattern.

The architectural picture — two granularity guarantees

Section titled “The architectural picture — two granularity guarantees”

Phaze gives you two granularity guarantees for lists, applied at different scales:

(1) <For>’s orphan per-item scopes (for.ts:62-87): array shape changes (add/remove) don’t blow away the bindings on items that survive reconciliation. The per-item effect() is created via runWith(null, () => effect(...)) — orphan from the outer For effect, so outer re-runs don’t cascade-dispose it. Listeners attached during the per-item render keep firing.

(2) store(t) per-property reactivity (this subpath): property value mutations don’t propagate to subscribers that didn’t read that specific property. Toggling .done doesn’t re-run .text bindings; it doesn’t fire the outer todos signal; it doesn’t re-run the outer <For> effect. The store proxy’s get trap creates a lazy per-property signal; the set trap only notifies that property’s subscribers.

Together they form a closed loop: a single user click on a checkbox → one todo.done = !todo.done property write → one class:line-through effect re-run → one classList.toggle('line-through', …) DOM operation. Nothing else moves. The outer For effect doesn’t run, the other items’ bindings don’t re-evaluate, the array reference doesn’t change, no DOM nodes get touched outside that one <span>’s classList.

That’s what makes the example feel instant. It’s also what makes the bundle small — there’s no broader scope being torn down and reconstructed, so there’s no broader machinery to ship. The runtime cost of a toggle in this pattern is one function call (the binding effect), one DOM write (the classList.toggle), and one fire-and-forget network call to the server.

import { s } from '@madenowhere/phaze/dsl'
import { store } from '@madenowhere/phaze/store'
import { remove } from '@madenowhere/phaze/list'
import { For } from '@madenowhere/phaze'
interface Todo { id: string; text: string; done: boolean }
export default function TodoList({ initial }: { initial: Todo[] }) {
// Each item is a store proxy — `t.done = …` fires only `.done`
// subscribers, the outer For doesn't reconcile on toggle.
const todos = s<Todo[]>(initial.map(store))
// Per-item closure — `<For>`'s orphan scopes keep the reference
// stable for the item's lifetime, so we can mutate it directly
// without a `.find()` lookup. The store fires `.done` → the
// per-item `class:line-through` effect re-runs surgically.
const onToggle = (todo: Todo) => {
todo.done = !todo.done
void toggleAction.execute({ id: todo.id })
}
const onRemove = ({ id }: Todo) => {
remove(todos, { id })
void removeAction.execute({ id })
}
const TodoItem = ({ todo }: { todo: Todo }) => (
<li>
<input type="checkbox" checked={todo.done} on:change={onToggle(todo)}/>
<span class:line-through={todo.done} class:text-neutral-400={todo.done} class="flex-1">
{todo.text}
</span>
<button on:click={onRemove(todo)}>×</button>
</li>
)
return (
<ul>
<For for:todo={todos}>
<TodoItem todo={todo}/>
</For>
</ul>
)
}

store(t) is the one line that makes the toggle reactive at one-property granularity. Drop it and the strikethrough freezes. The opt-in subpath is paying its rent.

The <For> above is the default (inversion) form — zero shipped For-runtime bytes, SSR-renders every row, rebuilds on todos.set(...). Each row’s class:line-through={todo.done} still re-runs surgically because todo is a store(...) proxy: the proxy identity survives the rebuild and the per-property signal keeps the same subscribers. Add the phaze flag (<For for:todo={todos} phaze> with an inner key={todo.id}) when row identity in the DOM has to survive reorders — focused inputs mid-type, drag-and-drop, in-flight animations. See <For> for the decision table.