Skip to content

Reactivity

Phaze’s reactive model is signal-based. There’s no virtual DOM, no fiber tree, no diff phase. Every reactive update flows along a fixed path: a signal changes → its subscriber list runs → each subscriber’s body executes → a DOM mutation (usually one) is performed. Components don’t re-render. There’s nothing to re-render.

This page covers what’s reactive in phaze, how that reactivity reaches the DOM, and where the boundary sits between “phaze manages this update” and “you’re writing to the DOM directly.”

Phaze has three concepts that work together. Get these right and the rest of phaze falls out of them.

Three concepts, one mental model

signaleffectbinding
what it isa value that can change over timea function that re-runs whenever a signal it read changesa one-line effect that writes one DOM slot
rolesource of truthconsumerthe bridge from reactivity to DOM
created bysignal(initial)effect(fn) (or implicitly by JSX, computed, store)the JSX runtime, when it sees a signal expression
does it own state?yes — holds the valueno — runs reactive work but has no return valueno — writes through to the DOM
lifetimeas long as it’s referenced (GC-managed)until disposed (manually or by parent re-run)tied to its enclosing effect
observable in infraredsignals/sec (writes) + named-signal paneleffects/sec (runs) + slowest-effects listbindings/sec (DOM writes) + DOM pulses

The relationship in one diagram:

signal.set(v)
notify subscribers
├─→ effect body ─→ manual work / DOM write
└─→ binding ─→ setText / setAttr / setClass / setStyle → one DOM mutation

A binding is just a specialized effect — it has nothing else in its body, just a call to setText (or one of the others) wrapping a signal read. The JSX runtime creates these for you when you write <span>{count()}</span>. You write effects manually (effect(() => …)) when the body needs to do more than write to one DOM slot.

Why this matters in practice:

  • When infrared shows you signals/sec, it’s counting .set() calls on signals.
  • When it shows effects/sec, it’s counting times an effect body re-ran (which equals the times one of its tracked signals changed).
  • When it shows bindings/sec, it’s counting actual DOM mutations from JSX-emitted bindings.
  • The three are not the same number. One signal change might fire one effect that produces zero DOM mutations (if the body doesn’t write to the DOM); or fire many effects (if many subscribed); or trigger a binding that writes one DOM slot.

These three rates together tell you whether your reactive work is doing useful DOM mutation or just reactive churn.

The reactive surface in phaze is small and explicit. Three primitives:

signal(initial?, options?) — the writable unit

Section titled “signal(initial?, options?) — the writable unit”

A signal is a function that returns its current value when called. Reading inside a running effect or computed auto-subscribes that effect to the signal; writing notifies all subscribers.

const count = signal(0)
count() // read — subscribes the active effect/computed
count.set(1) // write — notifies subscribers
count.current() // read without subscribing

The no-arg form signal<T>() returns Signal<T | undefined> — useful for DOM refs and other values that aren’t known at creation time.

computed(fn, options?) — derived, memoized

Section titled “computed(fn, options?) — derived, memoized”

A computed is a read-only signal whose value comes from a function of other signals. It tracks its own dependencies, recomputes lazily on read after invalidation, and notifies its own subscribers when its value actually changes.

const doubled = computed(() => count() * 2)
doubled() // 0; tracks count
count.set(5)
doubled() // 10; recomputed once

effect(fn, options?) — a function that re-runs

Section titled “effect(fn, options?) — a function that re-runs”

An effect is a function. It runs once when you create it, tracks every signal and computed it reads inside, and re-runs whenever any of those change. The body is whatever should happen when state changes — a DOM write, an event-listener attachment, a network call, anything. The return value of effect(fn) is a dispose function that stops the re-runs.

effect(() => {
console.log('count is', count())
})
count.set(1) // logs 'count is 1'

cleanup(fn) registered inside an effect body fires before the next re-run and on dispose.

What’s not reactive (and why it matters)

Section titled “What’s not reactive (and why it matters)”

Phaze does not auto-track:

  • Plain JS valueslet x = 0; x = 1 is invisible to phaze. Wrap in signal() if you need updates to propagate.
  • Object property mutationsstate.x = 1 on a plain object doesn’t notify. Use phaze/store for deeply-reactive proxies, or use signals + signal.update.
  • DOM property readsel.scrollTop, el.offsetWidth etc. aren’t reactive. Subscribe to events (scroll, resize) and update a signal.
  • Direct DOM writesel.style.x = '10px' mutates the DOM but nothing else. Phaze doesn’t see it.

The line between “phaze tracks this” and “phaze doesn’t” is whether the value lives behind a signal/computed or not. If it does, every read inside a running effect (or computed) subscribes that effect; every write through .set() notifies the subscribers.

A signal change becomes a DOM mutation through one of two paths.

Path 1 — JSX bindings (compiler/runtime emits these)

Section titled “Path 1 — JSX bindings (compiler/runtime emits these)”

When phaze’s JSX runtime sees a signal expression in your JSX, it wraps the read in an effect that calls one of four binding helpers. Each helper performs exactly one DOM operation and is the natural unit phaze counts.

binding helperfires when JSX has…DOM operation
setText<span>{count()}</span> — signal in text positionsets textNode.data
setAttribute<button disabled={() => loading()}> — signal as attributeel.setAttribute() / removeAttribute()
setClass<div class={() => active() ? 'on' : 'off'}> — signal as classel.className = ... or classList.toggle
setStyle<div style={() => ({ x: pos() })}> — signal as stylestyle.setProperty per key

A binding is not a signal.

One signal can drive many bindings; one binding listens to one effect’s body.

const count = signal(0)
return (
<div>
<span>{count}</span> {/* setText binding */}
<button disabled={() => count() > 10}> {/* setAttribute binding */}
step
</button>
<progress class={() => count() > 5 ? 'hot' : ''}>{/* setClass binding */}
{count} {/* another setText binding */}
</progress>
</div>
)

Every call to a binding helper is one DOM write. Devtools (@madenowhere/infrared) count these as bindings/sec to surface DOM-mutation rate, which is the most accurate proxy for actual layout/paint work.

Path 2 — direct effects + manual DOM writes

Section titled “Path 2 — direct effects + manual DOM writes”

You can always reach into the DOM yourself from inside an effect. The effect tracks reactivity; the DOM write is yours.

const node = signal<HTMLElement>()
effect(() => {
const el = node()
if (!el) return
el.style.transform = `translateY(${count()}px)`
})

This works — when count changes, the effect re-runs and writes the transform. But the runtime can’t see that you wrote style.transform. It sees an effect re-run; it doesn’t see what the body did.

This is the path most ecosystem libraries take when they need fine-grained control:

  • @madenowhere/photon’s photonProp(node, 'style:transform', signal, fmt) writes directly via node.style.transform = ... — fast, no JSX-runtime indirection
  • applyWarp(node, opts) writes node.style.rotate / .scale directly per RAF tick
  • An effect(() => { node.style.foo = signal() }) that bypasses the binding layer entirely (similar pattern)

These are correct and efficient. They just live outside phaze’s JSX-binding instrumentation, which means infrared’s bindings/sec counter doesn’t see them. You’d need to instrument photon/warp/etc. separately to count those writes.

If a value flows from a signal through a phaze JSX binding, infrared sees it and the binding helpers handle the DOM write.

If a value flows from a signal through an effect that calls el.style[X] = ... directly, you’ve stepped outside phaze’s binding layer. The reactivity still works — phaze tracks the effect — but the DOM write is uninstrumented.

For most apps the boundary doesn’t matter. It only becomes interesting when:

  1. You’re profiling and want accurate write-rate numbers (then route writes through setStyle / setAttribute helpers).
  2. You’re building a devtool that wants to highlight every DOM-mutating signal change (route through helpers, or instrument the direct path separately).
  3. You’re shipping a library with thousands of reactive nodes and the binding-helper indirection’s cost matters (then write direct, eat the instrumentation cost).

Every effect has three phases:

  1. Run — body executes, every signal read inside subscribes the effect.
  2. Cleanup — registered cleanup(fn) callbacks fire. Listeners on the active AbortSignal abort. Children dispose.
  3. Re-run — same body, re-tracking deps from scratch.

Disposal cascades through children: when a parent effect/computed disposes, its descendants dispose with it. This is how phaze guarantees no leaks across re-renders — every binding’s cleanup is reachable from the root effect that mounted the JSX subtree.

The cleanest mental model: a phaze app is one root effect (render(App)), which constructs a tree of nested effects (every binding, every component-body effect() call). The tree disposes top-down on unmount.

Signal writes go through an equality check before notifying. Default is Object.is; override per-signal:

const point = signal({ x: 0, y: 0 }, {
equals: (a, b) => a.x === b.x && a.y === b.y,
})
point.set({ x: 0, y: 0 }) // ignored — same value

If equality returns true, no subscribers fire — no effect runs, no DOM mutation, no binding write. This is one of the most effective perf tools phaze ships: a single equality check upstream skips the entire downstream graph for that change.

The corollary: a signal that’s frequently re-set to the same value is free. A signal that’s set with reference-different but structurally-equal objects re-fires unless you set custom equality.

  • API reference — every export in detail.
  • Store — opt-in deep proxies for nested data.
  • Infrared — surface effect runs, signal writes, and binding writes in real time.
  • Effects in Phaze — primer on the effect lifecycle, including the cleanup model.