Skip to content

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 this
import { Catch } from '@madenowhere/phaze/catch' // opt-in — production error fallback
import { Portal } from '@madenowhere/phaze/portal' // opt-in — modals / tooltips

There 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-chains
const 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. moveBefore preserves 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 connectedMoveCallback semantics (and on Element.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.

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:

  1. for:todo={todos}each={todos}, and the children wrap in a (todo) => … renderer arrow.
  2. An inner key={todo.id} lifts to getKey={(todo) => todo.id} on the <For> (the inner key is stripped). The lift bails silently when getKey is already explicit, there’s no key, the renderer is paramless / non-identifier-param / block-body, or the body is a fragment (ambiguous which key to lift).
  3. Inversion: no phaze and no getKey → rewrite to {() => todos().map((todo) => …)} and drop the For import; with phaze → strip the marker, keep the runtime shape; with a key/getKey but no phaze → a compile error pointing at the fix (a key implies “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.

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.

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))
})
FormWhen
<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.

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>

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>

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.