API reference
Everything exported from @madenowhere/phaze. The subpaths you import directly when used — phaze/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).
Signals
Section titled “Signals”signal(initial, options?) / signal<T>()
Section titled “signal(initial, options?) / signal<T>()”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-trackscount.set(1) // notifiescount.update(n => n + 1)count.current() // read without trackingcount.subscribe(v => console.log(v)) // returns unsubscribeOptions: { equals?: (a, b) => boolean, name?: string }. Default equality is Object.is.
No-arg overload — pending value
Section titled “No-arg overload — pending value”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 firesconst user = signal<User>() // undefined until fetch resolvesconst editing = signal<string>() // undefined when not editingThis 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 setreturn <div ref={box} class="..." /> // ref callback wires box.setcomputed(fn, options?)
Section titled “computed(fn, options?)”Memoized derived value. Tracks its own deps; readers track the computed itself. Recomputes lazily on read after invalidation.
const doubled = computed(() => count() * 2)doubled()effect(fn, options?)
Section titled “effect(fn, options?)”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.
batch(fn)
Section titled “batch(fn)”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 valueuntrack(fn)
Section titled “untrack(fn)”Run fn without auto-subscribing the active computation. New effects created inside still parent to the current owner so disposal cascades.
cleanup(fn)
Section titled “cleanup(fn)”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)”| Resource | React/Preact | Phaze |
|---|---|---|
Event listeners on JSX (onClick, etc.) | manual useEffect return | auto — Phaze attaches via AbortSignal, fires on dispose |
| Signal subscriptions | n/a | auto — when an effect re-runs or disposes |
Nested effect/computed inside an effect | n/a | auto — parent-child dispose cascade |
abortSignal() consumers (fetch, addEventListener) | manual AbortController | auto — 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>}When cleanup() is required
Section titled “When cleanup() is required”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())})Phaze vs Preact / React on cleanup
Section titled “Phaze vs Preact / React on cleanup”Both Preact and React rely on useEffect’s return-fn pattern:
// Preact / ReactuseEffect(() => { 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.
abortSignal()
Section titled “abortSignal()”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.})listen(target, type, handler, options?)
Section titled “listen(target, type, handler, options?)”Attach an event listener with automatic cleanup. Works for any EventTarget — HTMLElement, 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.)
Scheduling
Section titled “Scheduling”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.
Rendering
Section titled “Rendering”render(component, container)
Section titled “render(component, container)”Mount component() into container. Returns a dispose function that tears down the rendered tree, all bindings, and any effects.
hydrate(component, container)
Section titled “hydrate(component, container)”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.
Fragment
Section titled “Fragment”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:
| shape | behavior |
|---|---|
(el: Element) => void | classic 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 above | fans 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} />}Flow components
Section titled “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.
DOM primitives — phaze/dom
Section titled “DOM primitives — phaze/dom”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 functionsetAttribute(el, name, value)— set an attribute (reactive if value is a function)setClass(el, value)/setStyle(el, value)— class and style bindingstemplate(html)— compile-time-friendly DOM template cloneplaceNode(parent, node, anchor)—moveBefore-aware insert (state-preserving when supported)hasMoveBefore— boolean:truewhen the browser supportsElement.moveBefore()abortNode(node)— abort all listeners attached vialisten
Subpath exports
Section titled “Subpath exports”phaze/dsl— terse DSL aliases (s,c,watch,phaze) with compile-time auto-thunkingphaze/store— opt-in deep reactive proxiesphaze/catch—<Catch>error boundaryphaze/portal—<Portal>for modals / tooltipsphaze/time—interval()/timeout()with reactive auto-cleanup (see below)phaze/match— equality predicates on signals (method formstep.is(val)/step.not(val)and free-function formis(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 fordefer:idle/defer:visible/defer:media(see DSL ›defer:)phaze/dom— DOM primitives barrel (above)phaze/hydrate—hydrate()for adopting pre-rendered HTMLphaze/static—staticSubtree(html), the compile-emitted target forstaticSubtreeHoist(static subtrees skip per-element hydration)phaze/revalidate—withRevalidate(asyncSignal, seconds)for ISR-style periodic refresh ofsignal.asyncdataphaze/ssr-internal— internal SSR async-capture hooks used byrenderToStringAsync(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.
Duration helpers
Section titled “Duration helpers”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) // 10minutes(5) // 300hours(1) // 3600hours(1.5) // 5400days(3) // 259200weeks(2) // 1209600years(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”| Helper | Returns | Use |
|---|---|---|
seconds(n) | n | Cache 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)Sub-millisecond resolution
Section titled “Sub-millisecond resolution”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=Nis 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.
Reactive delay
Section titled “Reactive delay”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)How React does it
Section titled “How React does it”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:
- Stale closures. Functions captured by
setIntervalsee whatever state was current whenuseEffectran. To always read the latest value, you have to adduseRefand a seconduseEffectthat updates it. Dan Abramov’s classic “Making setInterval Declarative with React Hooks” exists because of this. - Forgetting the cleanup return. Easy to omit; the linter catches some cases but not all.
- Dependency-array gotchas. Anything the timer reads has to be in deps; missing one causes stale-closure bugs.
Did Preact improve on it?
Section titled “Did Preact improve on it?”No. Preact uses the same useEffect + manual cleanup pattern. @preact/signals doesn’t add a timer primitive. Same three footguns.
How Phaze innovates
Section titled “How Phaze innovates”Three real wins from the signals-native runtime:
-
No stale closures, ever. The callback reads signals via call (
count()), which always returns the current value. There’s nothing to capture incorrectly. NouseRefworkaround needed. -
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.
-
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 aneffect.
// Phaze // Reactimport { 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.
Side-by-side
Section titled “Side-by-side”| React | Preact | Phaze | |
|---|---|---|---|
| Built-in interval primitive | none | none | interval() from phaze/time |
| Stale closures (callback sees old state) | yes — needs useRef workaround | yes — same as React | none — signals are call-time reads |
| Manual cleanup return | required | required | auto via scope |
| Dep-array boilerplate | required | required | none — no dep array exists |
| Reactive delay (restart on change) | manual useEffect + useRef choreography | same | interval(() => ms(), fn) |
| Debounce-shaped timeout | needs lodash or useRef recipes | same | timeout(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:
- No stale closures — callbacks read signals via call (
count()); always current. - No dep array — Phaze tracks reactive deps automatically.
- 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.
Bundle cost
Section titled “Bundle cost”| 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 /time | 0 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 createsimport { 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 /dslimport { is, not } from '@madenowhere/phaze/match'
const step = signal<'email' | 'code' | 'done'>('email')is(step, 'email') // predicate-firstnot(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.
Which style when
Section titled “Which style when”| 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 formconst 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 formconst 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.
Why opt-in (rather than baked into core)
Section titled “Why opt-in (rather than baked into core)”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.
Coexists with phaze/dsl
Section titled “Coexists with phaze/dsl”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 aliasesimport { s } from '@madenowhere/phaze/match' // signals with .is / .nots 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.
Bundle cost
Section titled “Bundle cost”| 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 /match | 0 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):
// Sourceimport { 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 neededconst step = s<'email' | 'code' | 'done'>('email')<form class:hidden={step() !== 'email'}>…</form> // ← method call inlinedTree-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.
Type signatures
Section titled “Type signatures”inc: <T extends number>(sig: Signal<T>) => voiddec: <T extends number>(sig: Signal<T>) => voidadd: <T extends number>(sig: Signal<T>, n: number) => voidsub: <T extends number>(sig: Signal<T>, n: number) => voidT 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.
Combining with interval from phaze/time
Section titled “Combining with interval from phaze/time”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:
intervalsecond-arg auto-thunk —interval(1000, inc(count))→interval(1000, () => inc(count)). Mirrors theon:eventCallExpression auto-thunk; only fires when the second arg is a bare expression. Already-arrow / Identifier / Function args pass through unchanged./numericinline rewrite — the innerinc(count)becomescount.set(count() + 1), and the/numericimport declaration drops out.
Final runtime: interval(1000, () => count.set(count() + 1)). Zero bytes ship from /numeric, no source-level () => ceremony.
When the compiler can’t rewrite
Section titled “When the compiler can’t rewrite”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 tostate.count.set(state.count() + 1), duplicating the property access (potential side-effect double-trigger ifstate.countis a getter). The runtime fallback handles this safely. - The binding escapes as a value:
const handler = incorhandlers.bump = inc— the binding is captured by reference rather than invoked at a known call site. The compiler keeps the/numericimport 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.
Bundle cost
Section titled “Bundle cost”| 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 /numeric | 0 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 subsetCompile 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 ANDThe 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 signatures
Section titled “Type signatures”type ListPredicate<T> = (item: T) => booleantype ListMatch<T> = ListPredicate<T> | Partial<T>
remove: <T>(sig: Signal<T[]>, match: ListMatch<T>) => voidpush: <T>(sig: Signal<T[]>, item: T) => voidprepend: <T>(sig: Signal<T[]>, item: T) => voidreplace: <T>(sig: Signal<T[]>, match: ListMatch<T>, newItem: T) => voidpatch: <T>(sig: Signal<T[]>, match: ListMatch<T>, partial: Partial<T>) => voidmatches: <T>(match: ListMatch<T>) => ListPredicate<T>Partial<T> narrows the object-shorthand so misspelled keys fail at typecheck.
What’s NOT in /list
Section titled “What’s NOT in /list”Deliberate omissions:
pop/shift— rarely useful in reactive code; the intent is usually “remove the item with property X = Y” (useremove)sort/reverse— these belong in acomputed(() => [...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.
Coexistence with phaze/store
Section titled “Coexistence with phaze/store”/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.doneSee Components › For + per-item stores + closure handlers for the canonical TodoList composition.
Bundle cost
Section titled “Bundle cost”| 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 /list | 0 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.