API: Reactivity
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.