API: Flow components
Phaze ships three named flow components: <For> in core, <Catch> and <Portal> on opt-in subpaths. Each does something plain JS expressions can’t.
import { For } from '@madenowhere/phaze' // core — every list-rendering app uses thisimport { Catch } from '@madenowhere/phaze/catch' // opt-in — production error fallbackimport { Portal } from '@madenowhere/phaze/portal' // opt-in — modals / tooltipsThere is no <Show>, <Switch>, <Match>, or <Dynamic>. Control flow lives in JS, not JSX. For conditionals, write the same expressions you’d write in React — the compiler wraps reactive ones at build time:
{loggedIn() ? <Dashboard/> : <LoginButton/>} // ternary{showHelp() && <HelpPanel/>} // &&{user()?.name ?? 'Guest'} // ??
// open-ended multi-branch — pull into a computed, use if-chainsconst view = computed(() => { if (route() === '/home') return <HomePage/> if (route() === '/profile') return <Profile/> return <NotFound/>}){view}See Decisions › Why no <Dynamic> for the rationale.
<For> in Phaze is reactive even in SSR — at zero shipped bytes by default. That’s reactive lists at sub-3 KB. Server-renders every row into the HTML for SEO and first-paint, hydrates them in place on the client, re-renders on any signal change. The runtime For only ships when you opt in with the phaze flag — for the cases where row identity has to survive reorders.
<For for:todo={todos}> <TodoItem todo={todo}/></For>That’s the default. phaze-compile inverts it at build time to {() => todos().map(todo => <TodoItem todo={todo}/>)} — a reactive callback that re-evaluates on any read inside it. The For import drops from the bundle entirely once every <For> in the file is inversion-eligible. Same SSR/hydration story as any other reactive child: the SSR’d <li>s adopt during hydration, subsequent signal changes rebuild the list.
The phaze flag — opt into keyed runtime For
Section titled “The phaze flag — opt into keyed runtime For”When the list’s per-row state has to survive reorders, add phaze:
<For for:todo={todos} phaze> <TodoItem key={todo.id} todo={todo}/></For>The phaze attribute switches to the runtime <For> component: LIS-based reconciliation, Element.moveBefore() reorders, per-item orphan scopes (for.ts:62-87). Identity-by-key survives every array mutation — a row that’s still present after .set(reordered) is moved, not rebuilt.
Use the phaze flag when any of these apply:
- Focused inputs that take typing mid-list-update. The browser owns the cursor position and selection on
<input>/<textarea>. Default inversion rebuilds the row → focus is lost.moveBeforepreserves it. - Reorderable rows — drag-and-drop, manual sort, or a “move up / down” affordance. The whole point of
<For phaze>is that reorders are O(distance) DOM moves rather than O(n) rebuilds, and browser-native state (focus, video playback,<details>.open, iframe history) rides along. - Media inside rows —
<video>/<audio>mid-playback,<canvas>with accumulated state,<iframe>with navigation history. A rebuild interrupts playback and resets state. - In-flight CSS / Web Animations — an animation playing on a row would restart on rebuild but continue across
moveBefore. - Components that own native widgets — custom elements that depend on
connectedMoveCallbacksemantics (and onElement.moveBefore()to preserve their internal state across DOM moves).
For everything else — read-mostly lists, lists that grow/shrink at the end, lists where rebuild-on-change is fine — the default inversion is correct and ships nothing.
How for:NAME compiles
Section titled “How for:NAME compiles”for:NAME={signal} is <For>-specific sugar — valid only on <For> (a compile error anywhere else). Three local syntactic passes run on the outer element, no scope tracing:
for:todo={todos}→each={todos}, and the children wrap in a(todo) => …renderer arrow.- An inner
key={todo.id}lifts togetKey={(todo) => todo.id}on the<For>(the innerkeyis stripped). The lift bails silently whengetKeyis already explicit, there’s nokey, the renderer is paramless / non-identifier-param / block-body, or the body is a fragment (ambiguous which key to lift). - Inversion: no
phazeand nogetKey→ rewrite to{() => todos().map((todo) => …)}and drop theForimport; withphaze→ strip the marker, keep the runtime shape; with akey/getKeybut nophaze→ a compile error pointing at the fix (akeyimplies “preserve identity on reorder,” which needs the runtime For).
Each form, transformed
// Default (no phaze, no key) — inverts, 0 bytes shipped:<For for:todo={todos}> <TodoItem todo={todo}/></For>// ↓{() => todos().map((todo) => <TodoItem todo={todo}/>)}
// Keyed (phaze opt-in) — canonical runtime shape:<For for:todo={todos} phaze> <TodoItem key={todo.id} todo={todo}/></For>// ↓<For each={todos} getKey={(todo) => todo.id}> {(todo) => <TodoItem todo={todo}/>}</For>The explicit-arrow form skips the for: / key sugar and compiles to the same output — pick whichever reads better:
<For each={todos} phaze> {(todo) => <Todo key={todo.id} todo={todo}/>}</For>Default (no flag) — the canonical action-shaped list
Section titled “Default (no flag) — the canonical action-shaped list”The TodoList pattern: per-item store(...) proxies for granular reactivity + closure handlers + array-mutation helpers from phaze/list. The shape works the same way under both default-inversion and <For phaze> — the only difference is whether identity survives reorders.
import { s } from '@madenowhere/phaze/dsl'import { remove } from '@madenowhere/phaze/list'import { store } from '@madenowhere/phaze/store'import { For } from '@madenowhere/phaze'
// Per-item stores → property reads in JSX track per-property. Toggling// `t.done = !t.done` fires only `.done` subscribers — surgical update.const todos = s<Todo[]>(initial.map(store))
// Pass the per-item REFERENCE through handlers. `onToggle` mutates the// store property directly — no `.find()`, no id roundtrip.const onRemove = (todo: Todo) => remove(todos, { id: todo.id })const onToggle = (todo: Todo) => { todo.done = !todo.done }
const TodoItem = ({ todo }: { todo: Todo }) => ( <li> <input type="checkbox" checked={todo.done} on:change={onToggle(todo)}/> <span class:line-through={todo.done} class="flex-1">{todo.text}</span> <button on:click={onRemove(todo)} aria-label="delete">×</button> </li>)
<For for:todo={todos}> <TodoItem todo={todo}/></For>Three pieces compose: (1) each item is a store(...) proxy so class:line-through={todo.done} re-runs only when this item’s .done flips; (2) per-item on:click / on:change close over the item reference (stable for the row’s lifetime); (3) remove(todos, { id }) from phaze/list compiles to todos.set(todos().filter(_t => !(_t.id === todo.id))) and the /list import declaration disappears — zero shipped bytes.
Default-inversion + phaze/list together
Section titled “Default-inversion + phaze/list together”The two compose without ceremony. Every helper from phaze/list inlines to the canonical signal.set(...) form at build time, so a fully phaze/list-driven list ships only the <TodoItem/> body — no For runtime, no /list runtime.
import { s } from '@madenowhere/phaze/dsl'import { push, remove, patch } from '@madenowhere/phaze/list'import { For } from '@madenowhere/phaze'
const todos = s<Todo[]>([])
const onAdd = (text: string) => push(todos, { id: crypto.randomUUID(), text, done: false })const onRemove = (todo: Todo) => remove(todos, { id: todo.id })const onToggle = (todo: Todo) => patch(todos, { id: todo.id }, { done: !todo.done })
<For for:todo={todos}> <li> <input type="checkbox" checked={todo.done} on:change={onToggle(todo)}/> <span class="flex-1">{todo.text}</span> <button on:click={onRemove(todo)}>×</button> </li></For>push, remove, patch, prepend, replace, matches — six helpers, all compile-stripped. See phaze/list for the full reference.
Reconciling against server responses
Section titled “Reconciling against server responses”When the server returns the authoritative new state (an add that needs the server-generated id), preserve existing store proxies so per-item effects stay subscribed to the same instances. Under <For phaze>, this also keeps DOM identity for rows that were already present:
const { data } = await add.execute({ text })if (data) todos.update(prev => { const prevById = new Map(prev.map(t => [t.id, t])) return data.todos.map(t => prevById.get(t.id) ?? store(t))})Picking the form
Section titled “Picking the form”| Form | When |
|---|---|
<For for:item={items}> (default) | Reactive list, SSR-rendered, zero runtime bytes. Rows rebuild on signal change — fine when no browser-native state needs to survive. |
<For for:item={items} phaze> | Reorderable lists, focused inputs, media playback, in-flight animations — any case where row identity has to survive across items.set(...). Adds the runtime For (flow/for + dom/lis + dom/move, ~900 B brotli) to your phaze chunk. |
{items.map(t => <li key={t.id}>…</li>)} | The literal React shape. Equivalent to the default form for the rebuild semantics — key is a hint for fresh-mount identity but there’s no moveBefore. Pick this one when porting React/Preact code unchanged. |
<Portal>
Section titled “<Portal>”Subpath: phaze/portal. Renders children into a different DOM target (defaults to document.body). Reactivity from the surrounding scope still flows.
import { Portal } from '@madenowhere/phaze/portal'
<Portal mount={modalRoot}> <Modal onClose={...}/></Portal><Catch>
Section titled “<Catch>”Subpath: phaze/catch. Catches errors thrown during construction or in reactive callbacks within children. Fallback receives the error and a reset() callback. Named <Catch> rather than <Error> to avoid shadowing the global Error constructor.
import { Catch } from '@madenowhere/phaze/catch'
<Catch fallback={(err, reset) => <Failed err={err} retry={reset}/>}> <App/></Catch>Sharing state without prop-drilling
Section titled “Sharing state without prop-drilling”Phaze has no createContext / useContext, and will not be adding one. Use a module-level signal() — it does what context exists for, reactively, without a Provider tree. See the Decisions page for the rationale.