Skip to content

API: Reactivity

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.