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

An async-loaded reactive value. The loader runs at creation and re-runs whenever a signal read inside it changes — the dependency drives it, exactly as effect() is driven. Attached to the exported signal factory, so it needs no separate import.

const id = signal('initial')
const data = signal.async(() => fetch(`/api/${id()}`).then(r => r.json()))
data.pending() // reactive boolean — true while loading, TRUE at creation
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

The loader re-runs when id changes, and the in-flight request aborts automatically — see abortSignal() for wiring it into fetch.

The s.async(expr) call form. From phaze/dsl, the compiler auto-thunks a bare expression, so these are the same shipped code:

s.async(fetch(url).then(r => r.json())) // compiler wraps in () =>
signal.async(() => fetch(url).then(r => r.json()))

The optional second argument — s.async(loader, { revalidate: seconds }) — is compile sugar too: phaze-compile drops the options object and emits a withRevalidate sibling.

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 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.)

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.