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.
Why a separate import
Section titled “Why a separate import”- Headline budget intact.
phazestays sub-3 KB brotli; for typical usephaze/storeis 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/storeships in one version under that combined budget.
Compile-time optimization
Section titled “Compile-time optimization”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 likeform.email,form.count→ compile-inlined, 0 B from/store.store({ user: { name: '' } })— 2+ levels deep: needs reactiveform.user.name→ needs the runtime, ~400 B.
What the compile output looks like
// Sourceimport { 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.
store(target)
Section titled “store(target)”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' // notifiesstate.todos.push({ done: false }) // tracks lengthshallow(obj)
Section titled “shallow(obj)”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)$(store, key)
Section titled “$(store, key)”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'What’s tracked
Section titled “What’s tracked”- Property reads auto-subscribe the active reactive computation per-property.
- Property writes notify subscribers of that property only — siblings stay idle.
- Array
lengthupdates onpush/pop/splice/ direct index writes. - Iteration via
for..in,Object.keys,Object.entries,for..oftracks the keyset; new and deleted properties trigger re-runs. - Inherited methods like
Array.prototype.mapare not signal-cached but still track because they read indices via the proxy.
What isn’t tracked
Section titled “What isn’t tracked”- Maps and Sets. Throw on
store(). Wrap withshallow()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.
Reading without subscribing
Section titled “Reading without subscribing”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)})A case for store
Section titled “A case for store”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.
What we tried before introducing store(t)
Section titled “What we tried before introducing store(t)”| Attempt | Shape | Why it failed |
|---|---|---|
1. Plain objects + per-item on:click / on:change | todos = s<Todo[]>(initial), handlers via closure over t.id | Per-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 rebuild | Works (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 objects | One on:click / on:change on the parent reads data-id from the click target | Delegation 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 scope | installClasses(ul) reads todos() and walks <li> children via querySelector, applies classes manually | Worked, 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 fix | todos = s<Todo[]>(initial.map(store)) + runWith(null, …) in <For>’s mountItem | The 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:
| Without | What 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 missing | The original “only the most-recently-added item works” empirical disaster. |
| Both present | The 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.
The pattern in one example
Section titled “The pattern in one example”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.