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
signal
effect
binding
what it is
a value that can change over time
a function that re-runs whenever a signal it read changes
a one-line effect that writes one DOM slot
role
source of truth
consumer
the bridge from reactivity to DOM
created by
signal(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 value
no — runs reactive work but has no return value
no — writes through to the DOM
lifetime
as long as it’s referenced (GC-managed)
until disposed (manually or by parent re-run)
tied to its enclosing effect
observable in infrared
signals/sec (writes) + named-signal panel
effects/sec (runs) + slowest-effects list
bindings/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.
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.
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.
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.
Plain JS values — let x = 0; x = 1 is invisible to phaze. Wrap in signal() if you need updates to propagate.
Object property mutations — state.x = 1 on a plain object doesn’t notify. Use phaze/store for deeply-reactive proxies, or use signals + signal.update.
DOM property reads — el.scrollTop, el.offsetWidth etc. aren’t reactive. Subscribe to events (scroll, resize) and update a signal.
Direct DOM writes — el.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.
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 helper
fires when JSX has…
DOM operation
setText
<span>{count()}</span> — signal in text position
sets textNode.data
setAttribute
<button disabled={() => loading()}> — signal as attribute
el.setAttribute() / removeAttribute()
setClass
<div class={() => active() ? 'on' : 'off'}> — signal as class
el.className = ... or classList.toggle
setStyle
<div style={() => ({ x: pos() })}> — signal as style
style.setProperty per key
A binding is not a signal.
One signal can drive many bindings; one binding listens to one effect’s body.
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.
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:
You’re profiling and want accurate write-rate numbers (then route writes through setStyle / setAttribute helpers).
You’re building a devtool that wants to highlight every DOM-mutating signal change (route through helpers, or instrument the direct path separately).
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).
Run — body executes, every signal read inside subscribes the effect.
Cleanup — registered cleanup(fn) callbacks fire. Listeners on the active AbortSignal abort. Children dispose.
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.