Skip to content

API: Subpath exports

  • 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](/reference/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.