Skip to content

API reference

Everything exported from @madenowhere/phaze. The subpaths you import directly when usedphaze/store, phaze/portal, phaze/catch, phaze/time, phaze/match, phaze/numeric, phaze/list, phaze/revalidate, phaze/hydrate — are documented inline below or on their own pages.

The rest you don’t import by hand: phaze/static and phaze/defer are emitted by the compiler (from staticSubtreeHoist and the defer: namespace); phaze/dom is a low-level runtime/compiler barrel (only when you’re driving the DOM yourself); phaze/ssr-internal is SSR-only internal plumbing; and phaze/jsx-runtime / phaze/jsx-dev-runtime are the automatic JSX-transform targets (set once via jsxImportSource).

Create a writable reactive value. Reading inside a running effect or computed auto-subscribes that effect to the signal; writing notifies all subscribers.

const count = signal(0)
count() // 0 — auto-tracks
count.set(1) // notifies
count.update(n => n + 1)
count.current() // read without tracking
count.subscribe(v => console.log(v)) // returns unsubscribe

Options: { equals?: (a, b) => boolean, name?: string }. Default equality is Object.is.

For values that aren’t known at creation time (DOM refs, async-loaded data, optional state), call signal<T>() with no initial value. The signal type widens to Signal<T | undefined>; reads return undefined until the first .set(...).

const node = signal<HTMLElement>() // undefined until ref fires
const user = signal<User>() // undefined until fetch resolves
const editing = signal<string>() // undefined when not editing

This is the canonical pattern for “ref-as-signal,” because in phaze ref callbacks fire after effect bodies — using a signal lets effects re-run when the ref lands. Saves the signal<T | null>(null) boilerplate.

const box = signal<HTMLElement>()
photonProp(box, 'style:transform', y, fmt) // subscribes, attaches when set
return <div ref={box} class="..." /> // ref callback wires box.set

Memoized derived value. Tracks its own deps; readers track the computed itself. Recomputes lazily on read after invalidation.

const doubled = computed(() => count() * 2)
doubled()

Run fn immediately, then re-run whenever any signal it reads changes. Returns a dispose function.

const dispose = effect(() => {
console.log(count())
})
dispose()

Options: { name?: string } (the name is for devtools / @madenowhere/infrared). Phaze’s default scheduler is microtask-flushed and not priority-aware — see Primer › On priority and scheduling for the rationale and the opt-in path if you need it.

Group writes so dependents only re-run once. Nested batches collapse — only the outermost flushes.

batch(() => {
count.set(1)
count.set(2)
count.set(3)
}) // subscribers run once with the final value

Run fn without auto-subscribing the active computation. New effects created inside still parent to the current owner so disposal cascades.

Register a cleanup callback for the currently-running effect or computed. Fires before the next re-run and on dispose. No-op when called outside any effect.

effect(() => {
const t = setInterval(tick, 100)
cleanup(() => clearInterval(t))
})

What Phaze auto-cleans (no cleanup() needed)

Section titled “What Phaze auto-cleans (no cleanup() needed)”
ResourceReact/PreactPhaze
Event listeners on JSX (onClick, etc.)manual useEffect returnauto — Phaze attaches via AbortSignal, fires on dispose
Signal subscriptionsn/aauto — when an effect re-runs or disposes
Nested effect/computed inside an effectn/aauto — parent-child dispose cascade
abortSignal() consumers (fetch, addEventListener)manual AbortControllerauto — runtime aborts on re-run + dispose

So all of these already require zero cleanup():

// All auto-cleaned. Zero cleanup() calls.
function Tile({ tileId }) {
effect(() => {
fetch(`/t/${tileId()}`, { signal: abortSignal() }).then(...)
})
return <button onClick={() => save()}>{label}</button>
}

Vanilla JS or third-party APIs that Phaze has no knowledge of. JS doesn’t have a uniform “teardown this thing” protocol — each lifecycle is differently shaped.

effect(() => {
// Native timers — JS-built-in, Phaze doesn't manage them
const t = setInterval(tick, 100)
cleanup(() => clearInterval(t))
// Third-party SDK subscriptions
const sub = analytics.subscribe(handler)
cleanup(() => sub.unsubscribe())
// WebSocket / EventSource / WebRTC
const ws = new WebSocket(url)
cleanup(() => ws.close())
// IntersectionObserver / MutationObserver
const obs = new MutationObserver(fn)
obs.observe(el, opts)
cleanup(() => obs.disconnect())
})

Both Preact and React rely on useEffect’s return-fn pattern:

// Preact / React
useEffect(() => {
const t = setInterval(...)
return () => clearInterval(t) // cleanup is the return value
}, [])

Phaze splits the registration: effect() returns its dispose function (so the caller can tear the whole effect down), while cleanup(fn) registers per-resource teardown inside the effect. You can call cleanup multiple times for multiple resources without nesting them in a single return.

The marketing summary: Phaze auto-cleans more than React — event listeners, signal subscriptions, and aborted fetches are all handled by the runtime. The cleanup() calls that remain are unavoidable in any framework, because no runtime can know how a third-party SDK wants to be unsubscribed.

Returns the AbortSignal tied to the active computation’s lifetime. Pass to fetch, addEventListener, scheduler.postTask, etc., for one-call teardown when the computation re-runs or disposes. Lazy — calling once allocates the controller; computations that never call this pay zero.

import { s } from '@madenowhere/phaze/dsl'
import { abortSignal } from '@madenowhere/phaze'
const id = s('initial')
const data = s.async(
fetch(`/api/${id()}`, { signal: abortSignal() }).then(r => r.json())
)
// data.pending() reactive boolean — true while loading
// data.value() last successful payload, or undefined
// data.error() last error, or null — never an AbortError
// data.reload() re-run the loader without changing the deps
// Aborted automatically when `id` changes (re-run) or the component disposes.

The s.async(expr) form is the phaze-compiler auto-thunk over signal.async(() => Promise<T>). The compiler wraps the argument in () => at build time, so s.async(fetch(…).then(…)) and the explicit s.async(() => fetch(…).then(…)) produce identical runtime code. Same convention as c(expr) / watch(expr).

For non-signal.async uses — e.g. wiring abortSignal() into a plain effect() body — the import-and-call shape is identical:

import { effect } from '@madenowhere/phaze'
import { abortSignal } from '@madenowhere/phaze'
effect(() => {
fetch(`/api/${id()}`, { signal: abortSignal() }).then(...)
// Aborted automatically when the effect re-runs or disposes.
})

Attach an event listener with automatic cleanup. Works for any EventTargetHTMLElement, SVGElement, Window, Document, EventSource, MediaQueryList, custom EventTargets, etc. Phaze figures out the right cleanup composition based on the target shape:

  • Element target — listener auto-removed when the element is removed from the DOM (via render’s dispose). If a phaze effect is also active when listen() is called, that scope’s cleanup composes too: the listener fires until either the element is unmounted OR the effect re-runs/disposes, whichever comes first.
  • Non-Element target (Window, Document, EventSource, …) — cleanup runs through the active scope only. Calling listen() outside any scope on a non-element throws.
import { listen } from '@madenowhere/phaze'
effect(() => {
listen(window, 'resize', onResize, { passive: true }) // Window
listen(document, 'keydown', onKey) // Document
listen(button, 'click', onClick) // Element
// All removed automatically when the effect re-runs or disposes.
})

JSX onClick/onPointerDown/etc. props go through listen internally — use it directly when attaching outside JSX. (Earlier versions exposed a separate listenOn for non-element targets; that distinction is gone — one listen does both. listenOn remains as an alias for back-compat.)

Phaze’s default scheduler is microtask-flushed: when a signal change schedules an effect, it joins a single queue and runs on the next microtask. There is no withPriority / getPriority in the public API. Most apps never need priority-aware scheduling; for the ones that do, see Primer › On priority and scheduling for the use cases and the opt-in path Phaze plans for it.

Mount component() into container. Returns a dispose function that tears down the rendered tree, all bindings, and any effects.

Imported from the phaze/hydrate subpath:

import { hydrate } from '@madenowhere/phaze/hydrate'

Same as render, but adopts pre-existing DOM (e.g. SSR’d HTML) instead of creating fresh nodes. Used by @madenowhere/phaze-astro/client when the host element already has children. The hydrate machinery ships in every phaze-astro app’s bundle (the client picks between render and hydrate at runtime), so flipping an island from client:only to client:load costs zero bytes — see SSR & hydration for the full picture.

JSX fragment placeholder. Supplied by phaze/jsx-runtime; the automatic JSX transform imports it for you.

The JSX ref attribute on intrinsic elements accepts four shapes — phaze’s runtime fans them all out:

shapebehavior
(el: Element) => voidclassic ref callback — invoked once with the element
{ current: T | null }ref-shaped store — current is set to the element
Signal<T | undefined>phaze signal of element — signal.set(el) is called automatically
Array<…> of any of the abovefans out — every entry receives the element
// 1. Function ref (classic)
<div ref={(el) => console.log(el)} />
// 2. Object ref (React-style)
const ref = { current: null as HTMLDivElement | null }
<div ref={ref} />
// 3. Signal-as-ref (the typical phaze idiom)
const box = signal<HTMLDivElement>()
<div ref={box} /> // box.set(el) on mount
// 4. Array — multiple consumers on the same element
<div ref={[box.set, anotherRef, (el) => doThing(el)]} />

The signal-as-ref form pairs naturally with directives that take a target signal — photonProp(box, …), warp(box, …), etc. The array form replaces the cross-framework composeRefs helper: when one element needs multiple behaviors wired to it, just list every target.

There is no forwardRef. Phaze components don’t have an implicit second arg for refs — if a component wants to forward a ref to its inner element, accept ref as a normal prop and apply it.

function Card({ ref, ...rest }) {
return <div ref={ref} {...rest} />
}

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.

Low-level helpers used by the JSX runtime + compiler-emitted code. App authors don’t need these — listen / listenOn / abortSignal (the only DOM helpers most apps use directly) live on the main entry. Use phaze/dom only when you’re driving the DOM yourself.

import { setText, setAttribute, setClass, setStyle, template, placeNode, hasMoveBefore, abortNode } from '@madenowhere/phaze/dom'
  • setText(node, fn) — bind a text node to a reactive function
  • setAttribute(el, name, value) — set an attribute (reactive if value is a function)
  • setClass(el, value) / setStyle(el, value) — class and style bindings
  • template(html) — compile-time-friendly DOM template clone
  • placeNode(parent, node, anchor)moveBefore-aware insert (state-preserving when supported)
  • hasMoveBefore — boolean: true when the browser supports Element.moveBefore()
  • abortNode(node) — abort all listeners attached via listen
  • phaze/dsl — terse DSL aliases (s, c, watch, phaze) with compile-time auto-thunking
  • phaze/store — opt-in deep reactive proxies
  • phaze/catch<Catch> error boundary
  • phaze/portal<Portal> for modals / tooltips
  • phaze/timeinterval() / timeout() with reactive auto-cleanup (see below)
  • phaze/match — equality predicates on signals (method form step.is(val) / step.not(val) and free-function form is(step, val) / not(step, val) — see below)
  • phaze/numeric — number-signal helpers (inc, dec, add, sub) — compile-time-stripped (see below)
  • phaze/list — array-signal mutation helpers (remove, push, prepend, replace, patch) — compile-time-stripped (see below)
  • phaze/defer — deferred hydration; the lowering target for defer:idle / defer:visible / defer:media (see DSL › defer:)
  • phaze/dom — DOM primitives barrel (above)
  • phaze/hydratehydrate() for adopting pre-rendered HTML
  • phaze/staticstaticSubtree(html), the compile-emitted target for staticSubtreeHoist (static subtrees skip per-element hydration)
  • phaze/revalidatewithRevalidate(asyncSignal, seconds) for ISR-style periodic refresh of signal.async data
  • phaze/ssr-internal — internal SSR async-capture hooks used by renderToStringAsync (not for app code)
  • phaze/jsx-runtime, phaze/jsx-dev-runtime — automatic JSX transform targets

DSL & directives — phaze/dsl + JSX namespaces

Section titled “DSL & directives — phaze/dsl + JSX namespaces”

The @madenowhere/phaze/dsl subpath ships short aliases (s, c, watch, phaze) that the build-time compiler auto-thunks, so c(count() * 2) reads as c(() => count() * 2). Three JSX namespaces — phaze:attr for reactive attributes, on:event for native events, use:directive for behavior attachments — round out the authoring surface.

import { s, c, watch, phaze } from '@madenowhere/phaze/dsl'
import { autofocus, tooltip } from '@madenowhere/phaze-directives'
const name = s('')
const greeting = c(name() ? `Hello, ${name()}!` : 'Type your name')
<input
use:autofocus={true}
use:tooltip={"Your name"}
phaze:value={name()}
on:input={(e) => name.set(e.currentTarget.value)}
/>
<p>{phaze(`> ${greeting()}`)}</p>

Every transform on this surface is compile-time only — no runtime cost over hand-written equivalents. See the DSL & directives page for the complete reference (vocabulary, namespace semantics, the directive contract, the canonical @madenowhere/phaze-directives package, and how to author your own).

phaze/time — timer helpers with reactive auto-cleanup

Section titled “phaze/time — timer helpers with reactive auto-cleanup”
import { interval, timeout, hours, days } from '@madenowhere/phaze/time'
import { inc } from '@madenowhere/phaze/numeric'
effect(() => {
// Auto-cleared when this effect re-runs or disposes.
// Two compile transforms cascade here:
// 1. /time auto-thunks the second arg → interval(100, () => inc(count))
// 2. /numeric inlines inc(count) → interval(100, () => count.set(count() + 1))
// The /numeric import drops entirely at build time — zero shipped bytes.
interval(100, inc(count))
})

The subpath exports two categories of helper: scope-aware timers (interval / timeout — the headline) and duration literals (seconds / minutes / hours / days / weeks / years / ms — covered in Duration helpers). Both ride the phaze/time import path. Importing only duration helpers ships zero bytes; importing interval or timeout brings the ~70 B phaze-time chunk.

Human-readable seconds-domain literals that compile to numeric constants. Designed for cache TTLs (revalidate, Cache-Control, CDN headers) and other places where integer-seconds is the canonical unit.

import { seconds, minutes, hours, days, weeks, years, ms } from '@madenowhere/phaze/time'
seconds(10) // 10
minutes(5) // 300
hours(1) // 3600
hours(1.5) // 5400
days(3) // 259200
weeks(2) // 1209600
years(1) // 31536000 (365 days — matches HTTP convention)
ms(500) // 500 (identity — documentary at setTimeout sites)

Phaze-compile rewrites each call to its numeric literal at AST time, then prunes the specifier from the import declaration. Production bundles never reference the helper names — they ship as plain integers everywhere they’re called.

Canonical use — page-level cache directive in phaze-cloudflare:

import { hours, days } from '@madenowhere/phaze/time'
export const revalidate = { maxAge: hours(1), swr: days(7) }
// compiles to: revalidate = { maxAge: 3600, swr: 604800 }

Reads better at the call site than { maxAge: 3600, swr: 604800 } — and TypeScript narrows arguments to number, so a leftover seconds(60) after a copy-paste from a Cache-Control example still type-checks cleanly.

Unit choice — seconds for the family, ms as the timer-domain alias

Section titled “Unit choice — seconds for the family, ms as the timer-domain alias”
HelperReturnsUse
seconds(n)nCache TTL, CDN max-age
minutes(n)n * 60""
hours(n)n * 3600""
days(n)n * 86400""
weeks(n)n * 604800""
years(n)n * 31536000 (365 days)“Immutable” Cache-Control
ms(n)n (identity)setTimeout, setInterval, interval()/timeout()

The split: the seconds family matches HTTP/CDN convention — Cache-Control: max-age=N is integer seconds per RFC 9111, and every CDN edge spec follows. The ms helper is identity — it doesn’t transform the value, it documents the unit at the call site so reading interval(ms(500), fn) is unambiguous about what 500 means.

years(n) uses 365 days (31_536_000 seconds), not 365.25 — this matches the de-facto HTTP convention. Cache-Control: max-age=31536000 is the canonical “one year, immutable” value used by every major CDN doc, nginx/Apache defaults, and RFC 9111 examples. Internal consistency holds: years(1) and days(365) produce the same value.

When mixing units, the literal-arithmetic also folds at compile time:

interval(seconds(2) * 1000, fn)
// → interval(2000, fn)

There’s no microseconds() or nanoseconds() helper, by design. JS doesn’t have a Number-typed API that consumes sub-ms values:

  • setTimeout(0.5, fn) is silently coerced to 0 (HTML spec enforces a 4ms floor anyway).
  • Cache-Control: max-age=N is integer seconds per RFC 9111 — sub-second TTL is unspecifiable.
  • performance.now() returns fractional milliseconds; modern browsers clamp the precision to ~1ms for Spectre mitigation.
  • The only Number-typed nanosecond API is process.hrtime.bigint() — and it returns BigInt, not Number. Workers don’t have it at all.

A helper that compiled microseconds(500) → 0.5 would suggest precision the platform can’t deliver. Better omitted.

Both interval() and timeout() accept either a number or a thunk-of-number. With a thunk, the timer restarts when the thunk’s tracked deps change.

A signal IS a thunk — Signal<number> is structurally () => number — so you can pass the signal directly. No () => ms() wrapping, no surrounding effect(): interval() creates its own effect and registers its own cleanup, so the timer is already scope-aware on its own.

const ms = signal(1000)
// Restarts the interval every time `ms` changes; auto-cleared when the
// surrounding component / module scope disposes.
interval(ms, tick)

For timeout, the reactive-delay form gives you a debounce shape — the countdown restarts every time a tracked signal in the delay thunk changes, so the body only fires once the signal has been stable for the full delay. The cleanest user-facing form is the 3-arg sugar timeout(restart, delay, fn), which phaze-compile rewrites at build time:

const draft = signal('')
effect(() => {
timeout(draft, 1000, saveDraft(draft.current()))
// │ │ │
// │ │ callback (auto-thunked)
// │ delay in ms (static number)
// restart trigger — `draft()` is called inside the wrapping thunk,
// so its signal reads subscribe and the
// countdown restarts on change.
})

What phaze-compile emits at build time:

timeout(
() => { draft(); return 1000 }, // canonical 2-arg form's first arg
() => saveDraft(draft.current()), // canonical 2-arg form's second arg
)

React has no built-in interval primitive. The standard pattern is useEffect with manual cleanup:

useEffect(() => {
const id = setInterval(() => setCount(c => c + 1), 1000)
return () => clearInterval(id)
}, [])

Three known footguns:

  1. Stale closures. Functions captured by setInterval see whatever state was current when useEffect ran. To always read the latest value, you have to add useRef and a second useEffect that updates it. Dan Abramov’s classic “Making setInterval Declarative with React Hooks” exists because of this.
  2. Forgetting the cleanup return. Easy to omit; the linter catches some cases but not all.
  3. Dependency-array gotchas. Anything the timer reads has to be in deps; missing one causes stale-closure bugs.

No. Preact uses the same useEffect + manual cleanup pattern. @preact/signals doesn’t add a timer primitive. Same three footguns.

Three real wins from the signals-native runtime:

  1. No stale closures, ever. The callback reads signals via call (count()), which always returns the current value. There’s nothing to capture incorrectly. No useRef workaround needed.

  2. No dep array. The surrounding effect tracks deps automatically — every signal you read inside it subscribes. The thunk-delay form makes “restart when this signal changes” a one-liner.

  3. Auto-cleanup is structural, not opt-in. interval() registers itself with the surrounding effect on creation. There’s no return-fn to remember; you literally cannot leak a timer that was created inside an effect.

// Phaze // React
import { interval } from '@madenowhere/phaze/time' useEffect(() => {
const savedCb = useRef()
effect(() => { useEffect(() => { savedCb.current = callback })
interval(() => delay(), () => { const id = setInterval(
fn(value()) () => savedCb.current(),
}) delay
}) )
return () => clearInterval(id)
}, [delay])
// (the `useInterval` recipe — paraphrased)

The “innovation” isn’t the timer functions themselves — it’s that the signals-native architecture erases all three React footguns for free. The wrapper just exposes that structurally.

ReactPreactPhaze
Built-in interval primitivenonenoneinterval() from phaze/time
Stale closures (callback sees old state)yes — needs useRef workaroundyes — same as Reactnone — signals are call-time reads
Manual cleanup returnrequiredrequiredauto via scope
Dep-array boilerplaterequiredrequirednone — no dep array exists
Reactive delay (restart on change)manual useEffect + useRef choreographysameinterval(() => ms(), fn)
Debounce-shaped timeoutneeds lodash or useRef recipessametimeout(sig, 300, fn) (3-arg sugar — phaze-compile rewrites to the canonical 2-arg form)

Preact didn’t improve on React’s interval story — same useEffect + manual cleanup pattern, same three footguns. @preact/signals doesn’t add a timer primitive either.

Phaze’s innovation isn’t the timer functions themselves. It’s that the signals-native architecture eliminates all three footguns structurally:

  1. No stale closures — callbacks read signals via call (count()); always current.
  2. No dep array — Phaze tracks reactive deps automatically.
  3. No cleanup return to forget — auto-cleanup is part of registering the timer inside an effect.

The wrapper just exposes that. Zero footguns. Opt-in via phaze/time — apps that don’t import it pay nothing.

Brotli
Duration helpers (seconds/minutes/hours/days/weeks/years/ms)0 B — each call folds to a NumericLiteral at AST time; the specifier prunes from the import declaration. The phaze-time chunk isn’t emitted unless interval/timeout are also imported.
interval / timeout (true-runtime helpers)~70 B (measured as phaze + phaze/time minus phaze (full) via the per-module size script — just the wrapper bodies; effect and cleanup are already in phaze (full))
Apps that don’t import /time0 B — tree-shaken via sideEffects: false

Importing the duration helpers alongside interval/timeout doesn’t grow the phaze-time chunk. seconds(10), hours(1), etc. fold to literals and prune from the import declaration before Rollup runs — so a file with import { interval, hours } from 'phaze/time'; interval(...); hours(1) compiles to a post-pass shape identical to import { interval } from 'phaze/time'; interval(...); 3600. The chunk’s +70 B is paid for interval/timeout’s scope-aware machinery alone.

The “subpath alone” measurement (~0.53 KB) includes effect + cleanup transitively from phaze core. Those symbols already ship in any phaze-using app via @madenowhere/phaze, so the realistic marginal of adding /time is the ~70 B above.

For the canonical real-app number (your actual app’s phaze chunk + every other phaze chunk), add the sizeReport() plugin from @madenowhere/vite-plugin-phaze to your vite.plugins[]. It runs at the end of every vite build and emits the real Rollup-bundled chunk sizes — accurate, in-app, honest. The per-module numbers above are diagnostic approximations; sizeReport() is the source of truth for “what does my app actually ship”.

phaze/match — equality predicates on signals

Section titled “phaze/match — equality predicates on signals”

Two import styles, pick the one that reads best at your call sites:

// Method style — augmented signal factory; methods on every signal it creates
import { signal } from '@madenowhere/phaze/match'
const step = signal<'email' | 'code' | 'done'>('email')
step.is('email') // method — receiver-first ("step is email")
step.not('email')
// Free-function style — works with ANY signal (core, /dsl, computed, anything callable)
import { signal } from '@madenowhere/phaze' // or /dsl
import { is, not } from '@madenowhere/phaze/match'
const step = signal<'email' | 'code' | 'done'>('email')
is(step, 'email') // predicate-first
not(step, 'email')

Both forms have identical tracking semantics: they call the signal’s read inside the caller’s tracking context (computed bodies, effects, the IIFE that phaze-compile wraps class: and bind: ops in), so the active subscription registers on every evaluation. Use .current() directly if you need an untracked comparison.

Use the method form when…Use the free-function form when…
You’re constructing the signal in the same file. const step = signal(...) lets step.is(...) flow naturally.The signal came from elsewhere — computed(), a module-level signal imported across files, useAction’s reactive resources, anything you didn’t construct via /match.
You like receiver-first English-order reading: “step is email”.You like the predicate-first reading common in functional code.
You want autocomplete on the signal itself to surface .is / .not.You want the import line to literally name the helpers you’re using.

The two styles cost the same at runtime. The free functions can target any callable signal; the methods are scoped to signals created via /match’s factory. Mix freely across a codebase.

The idiomatic use case — union-typed state-machine signals

Section titled “The idiomatic use case — union-typed state-machine signals”
// Method form
const step = signal<'email' | 'code' | 'done'>('email')
<form class:hidden={step.not('email')} on:submit={requestCode}></form>
<form class:hidden={step.not('code')} on:submit={verifyCode}></form>
<div class:hidden={step.not('done')}>Access granted.</div>
// Free-function form
const step = signal<'email' | 'code' | 'done'>('email')
<form class:hidden={not(step, 'email')} on:submit={requestCode}></form>
<form class:hidden={not(step, 'code')} on:submit={verifyCode}></form>
<div class:hidden={not(step, 'done')}>Access granted.</div>

Both read as: “hide this when step is not email.” See Primer › State in Phaze for the full pattern, why it’s safe (compile-time effect wrapping), and a comparison against userland helpers.

Phaze’s runtime ships only what’s actually used. Plenty of phaze code never touches a union-typed signal — those projects shouldn’t pay bytes for .is/.not they never call. Same pattern as phaze/store, phaze/portal, phaze/catch, phaze/time, and the planned phaze/scheduler: one import line buys you the feature; not importing costs nothing.

The DSL subpath (/dsl) ships compile-time-aware aliases (s, c, watch, phaze); the match subpath (/match) ships the predicate-method-augmented signal. Most apps that want both write:

import { c, watch, phaze } from '@madenowhere/phaze/dsl' // compile-time-aware aliases
import { s } from '@madenowhere/phaze/match' // signals with .is / .not

s from /match is just an alias for signal (same as s is an alias for signal in /dsl). Phaze-compile only rewrites identifiers traced from /dsl imports; s from /match runs the plain runtime factory — which is fine, because signal() creation doesn’t need compile-time auto-thunking. Functions called c / watch / phaze do need it, so import those from /dsl.

Brotli
Marginal cost in a phaze-using app, compiler present (every call site rewritten)0 B — both the call sites AND the factory import drop entirely
Marginal cost in a phaze-using app, no compiler (runtime fallback ships)~60 B for the factory wrapper + free helpers (measured as phaze + phaze/match minus phaze (full))
Apps that don’t import /match0 B — tree-shaken via sideEffects: false

sideEffects: false + tree-shaking + the compile-time rewrite mean importing /match is free in any real production build. The runtime fallback only ships when phaze-compile isn’t in the pipeline (vitest without the transform, ts-node REPLs, etc.), OR when phaze-compile detects an escape pattern for a factory-created signal (passed as a JSX prop, exported, destructured, aliased, method-as-value, or used as a function argument — all cases where the augmented .is/.not methods need to exist at runtime because they may be called from outside the file’s compile pass).

The “subpath alone” measurement (~1.35 KB) includes phaze-core transitive deps (signal, cleanup, etc.) that any phaze app already ships. The realistic marginal is the ~60 B above for the no-compiler fallback path.

What the compile transform does (phaze-compile):

// Source
import { s } from '@madenowhere/phaze/match'
const step = s<'email' | 'code' | 'done'>('email')
<form class:hidden={step.not('email')}>…</form>
// After phaze-compile (production output)
import { s } from '@madenowhere/phaze/dsl' // ← swapped to /dsl — no augmentation needed
const step = s<'email' | 'code' | 'done'>('email')
<form class:hidden={step() !== 'email'}></form> // ← method call inlined

Tree-shaking applies — importing only { is, not } from /match drops the augmented factory from your bundle, and vice versa.

phaze/numeric — number-signal helpers (compile-time-stripped)

Section titled “phaze/numeric — number-signal helpers (compile-time-stripped)”

Four type-narrowed helpers for Signal<number>. The phaze-compiler inlines each call at build time and drops the import declaration entirely — production bundles ship zero bytes from this subpath. The runtime bodies exist as a no-compiler fallback (vitest, ts-node, the TS playground); the precedent matches c / watch / phaze in /dsl.

import { inc, dec, add, sub } from '@madenowhere/phaze/numeric'
import { s } from '@madenowhere/phaze/dsl'
const count = s(0)
const fuel = s(50)
inc(count) // count.set(count() + 1)
dec(count) // count.set(count() - 1)
add(fuel, 5) // fuel.set(fuel() + 5)
sub(fuel, 2) // fuel.set(fuel() - 2)

The right-hand-side column is what phaze-compile emits at every call site. The import { inc, dec, add, sub } from '@madenowhere/phaze/numeric' declaration disappears in the compiled output once every binding’s references have been rewritten.

inc: <T extends number>(sig: Signal<T>) => void
dec: <T extends number>(sig: Signal<T>) => void
add: <T extends number>(sig: Signal<T>, n: number) => void
sub: <T extends number>(sig: Signal<T>, n: number) => void

T extends number lets the helpers work on narrowed number types (Signal<0 | 1>, Signal<-1 | 0 | 1>, etc.) — the as T cast inside each implementation preserves the type at the write site.

import { interval } from '@madenowhere/phaze/time'
import { inc } from '@madenowhere/phaze/numeric'
import { s } from '@madenowhere/phaze/dsl'
const count = s(0)
interval(1000, inc(count))
// delay │ tick callback (bare expression — no arrow needed)

Two compile-time transforms compose:

  1. interval second-arg auto-thunkinterval(1000, inc(count))interval(1000, () => inc(count)). Mirrors the on:event CallExpression auto-thunk; only fires when the second arg is a bare expression. Already-arrow / Identifier / Function args pass through unchanged.
  2. /numeric inline rewrite — the inner inc(count) becomes count.set(count() + 1), and the /numeric import declaration drops out.

Final runtime: interval(1000, () => count.set(count() + 1)). Zero bytes ship from /numeric, no source-level () => ceremony.

The compile-time rewrite is conservative — it only fires when the first argument is an Identifier. The two cases that bail and use the runtime fallback instead:

  • Member-expression first arg: inc(state.count) would expand to state.count.set(state.count() + 1), duplicating the property access (potential side-effect double-trigger if state.count is a getter). The runtime fallback handles this safely.
  • The binding escapes as a value: const handler = inc or handlers.bump = inc — the binding is captured by reference rather than invoked at a known call site. The compiler keeps the /numeric import with just the escaping specifier so the runtime can resolve it.

Both cases continue to work — they just pay the ~25-byte-per-helper runtime cost instead of getting the inline expansion.

Brotli
Marginal cost in a phaze-using app, compiler present (every call site rewritten)0 B — import drops entirely
Marginal cost in a phaze-using app, no compiler (runtime fallback ships)~50 B for the four-helper runtime fallback (measured as phaze + phaze/numeric minus phaze (full))
Apps that don’t import /numeric0 B — tree-shaken via sideEffects: false

sideEffects: false + tree-shaking + the compile-time rewrite mean importing /numeric is free in any real production build. The runtime fallback only ships when phaze-compile isn’t in the pipeline (vitest without the transform, ts-node REPLs, etc.).

The “subpath alone” size you’d see in the size report (~90 B brotli) includes a tiny amount of phaze-core symbols pulled in transitively for type definitions; the realistic marginal cost in an app that already uses @madenowhere/phaze is the ~50 B above.

phaze/list — array-signal mutation helpers (compile-time-stripped)

Section titled “phaze/list — array-signal mutation helpers (compile-time-stripped)”

Six helpers for the common case where a signal’s value is an array and you want to mutate it without writing signal.update(prev => prev.<arr-method>(…)) at every call site. Same architectural shape as /numeric and /match: one subpath import, phaze-compile inlines each call to the canonical signal.set(...) form, the import declaration drops in production — zero shipped bytes when the compiler is in the pipeline.

import { s } from '@madenowhere/phaze/dsl'
import { remove, push, prepend, replace, patch, matches } from '@madenowhere/phaze/list'
interface Todo { id: string; text: string; done: boolean }
const todos = s<Todo[]>([])
remove(todos, { id }) // todos.set(todos().filter(_t => !(_t.id === id)))
push(todos, newTodo) // todos.set([...todos(), newTodo])
prepend(todos, newTodo) // todos.set([newTodo, ...todos()])
replace(todos, { id }, updated) // todos.set(todos().map(_t => _t.id === id ? updated : _t))
patch(todos, { id }, { done: true }) // todos.set(todos().map(_t => _t.id === id ? { ..._t, done: true } : _t))
matches({ id }) // (_t) => _t.id === id (an inline arrow at the use site)

The right-hand column is what phaze-compile emits at every call site. The import declaration disappears once every binding’s references have been rewritten.

matches is the predicate factory — it converts the same { key: value } shorthand the other helpers accept into a (item) => boolean predicate, for use with the native array methods that take a predicate: find, some, every, filter, findIndex, findLast, findLastIndex.

const item = todos().find(matches({ id })) // = todos().find(t => t.id === id)
todos().some(matches({ done: true })) // any item done?
todos().every(matches({ done: true })) // all done?
todos().filter(matches({ kind: 'archived' })) // filter to subset

Compile output for matches({ id }) is a plain inline arrow (_t) => _t.id === id — same property-equality AND-chain machinery the mutation helpers use, exposed as a building block.

The match argument — predicate or partial-object

Section titled “The match argument — predicate or partial-object”

remove, replace, and patch all take a match argument that accepts two shapes:

// Predicate form — `(item) => boolean`. The general case.
remove(todos, (t) => t.priority > 5)
// Partial-object shorthand — `{ key: value, … }`. Reads as
// "match where every listed property equals the given value."
// Object-property shorthand `{ id }` makes the common case
// (matching by a single id field) one expression.
remove(todos, { id })
remove(todos, { id, kind: 'archived' }) // multi-property AND

The object-shorthand is the killer feature — it turns “remove the todo with this id” from the implementation-shaped prev => prev.filter(t => t.id !== id) into the intent-shaped remove(todos, { id }). The compiler emits the property-equality conjunction at the call site.

type ListPredicate<T> = (item: T) => boolean
type ListMatch<T> = ListPredicate<T> | Partial<T>
remove: <T>(sig: Signal<T[]>, match: ListMatch<T>) => void
push: <T>(sig: Signal<T[]>, item: T) => void
prepend: <T>(sig: Signal<T[]>, item: T) => void
replace: <T>(sig: Signal<T[]>, match: ListMatch<T>, newItem: T) => void
patch: <T>(sig: Signal<T[]>, match: ListMatch<T>, partial: Partial<T>) => void
matches: <T>(match: ListMatch<T>) => ListPredicate<T>

Partial<T> narrows the object-shorthand so misspelled keys fail at typecheck.

Deliberate omissions:

  • pop / shift — rarely useful in reactive code; the intent is usually “remove the item with property X = Y” (use remove)
  • sort / reverse — these belong in a computed(() => [...todos()].sort(…)), not as a signal mutation (sorting in place would lose the ability to derive other views)
  • splice / map / forEach — too low-level; signal.update(transformer) is the right fall-through when no helper fits

If you need an op that isn’t here, signal.update(prev => …) is always available — /list is sugar over the common shapes, not a wrapper over every array method.

/list’s helpers mutate the signal (produce a new array reference, fire the signal, consumers reconcile). [phaze/store](/store/) mutates the proxy (per-property notify via proxy traps). They compose naturally:

import { s } from '@madenowhere/phaze/dsl'
import { store } from '@madenowhere/phaze/store'
import { remove } from '@madenowhere/phaze/list'
interface Todo { id: string; text: string; done: boolean }
const todos = s<Todo[]>(initial.map(store))
// Array shape changes → use /list (outer signal fires, For reconciles)
remove(todos, { id })
// Per-property changes → mutate the store directly (granular, no array re-create)
const item = todos().find(t => t.id === id)
if (item) item.done = !item.done

See Components › For + per-item stores + closure handlers for the canonical TodoList composition.

Brotli
Marginal cost in a phaze-using app, compiler present (every call site rewritten)0 B — import drops entirely
Marginal cost in a phaze-using app, no compiler (runtime fallback ships)~100 B for the five-helper runtime fallback (measured as phaze + phaze/list minus phaze (full))
Apps that don’t import /list0 B — tree-shaken via sideEffects: false

sideEffects: false + tree-shaking + the compile-time rewrite mean importing /list is free in any real production build. The runtime fallback only ships when phaze-compile isn’t in the pipeline (vitest without the transform, ts-node REPLs, etc.), OR when a call site uses a non-literal match argument (dynamically-computed predicate) that the compiler can’t inline.