Skip to content

Effects in Phaze

Phaze’s effect() is the same shape as the TC39 Signals proposal (Stage 1)‘s Signal.subtle.Watcher: a reactive subscription that tracks signal reads, fires on change, supports cleanup, and disposes deterministically. The proposal calls this the “subtle” API — the low-level primitive framework authors build effect APIs on top of. Phaze’s effect() is that effect API, exposed directly.

Concretely, the spec contract Phaze already implements:

  • Read-tracking — calling signal() or computed() inside an effect() body auto-subscribes; no dep array.
  • Notify-on-change — when a tracked signal’s value changes (using Object.is equality, the spec’s default), the watcher is notified.
  • Lazy recomputationcomputed() runs only when read AND its inputs have actually changed; intermediate writes don’t fire downstream.
  • Glitch-free — within a synchronous batch of writes, downstream effects see a consistent final state, never an intermediate one. Phaze’s batch() matches the spec’s batched-notify semantics.
  • Deterministic disposeconst dispose = effect(...) returns a function the spec also describes; cleanup callbacks fire once, in registration order, when dispose runs or the effect re-runs.

Until browsers ship the native API, Phaze provides the semantics in sub-3 KB. When they ship it, app code reads the same way — only the imports change. Phaze’s dispose handles are also forward-compatible with Explicit Resource Management (Stage 3): the day using lands, dispose handles can opt into Symbol.dispose without changing cleanup semantics.


If you’ve shipped React or Preact for any length of time, you have scar tissue from useEffect.

You’ve stared at an empty dep array wondering if it’s actually empty. You’ve added // eslint-disable-next-line react-hooks/exhaustive-deps and felt bad. You’ve written the AbortController + ignore-flag dance to stop a stale fetch from clobbering a fresh one. You’ve debugged a setInterval that captured a stale count. You’ve learned that StrictMode runs your effect twice in dev and rewritten it to be idempotent. You’ve reached for useCallback not because you wanted memoization but because something downstream needed referential stability for its deps array.

This page is about what changes when the runtime doesn’t make you do any of that.

Before going further, it’s worth saying out loud: “useEffect” was a borrowed word, used wrong, and a generation of frontend devs learned the wrong meaning of “effect” because of it.

In the reactive-programming tradition — spreadsheets, FRP, KnockoutJS, MobX, S.js, Vue’s watchEffect, Solid’s createEffect, Preact Signals’ effect, Svelte’s $effect — an effect is a node in a dependency graph that re-runs when its inputs change. It auto-tracks. It re-runs on data, not on lifecycle. That’s what the word has meant in reactive systems for decades.

React’s useEffect adopted the name but not the semantics. It doesn’t auto-track. It doesn’t run on data change — it runs after a component renders and commits, gated by a manually-maintained array. The thing it’s named after (a reactive effect) and the thing it actually is (a post-commit lifecycle callback with a manual dep filter) are different concepts.

This caused real damage. React’s market share is large enough that for a lot of working developers, “effect” means “the thing in a dep array that runs after render.” When Solid or Vue or Phaze says “effect,” those developers reasonably hear “useEffect-but-different-syntax” and import all the failure modes — they ask for a dep array, they wonder about double-firing, they worry about stale closures, they use useCallback-equivalents that don’t exist and aren’t needed.

The point of this chapter is to put that reflex down. In Phaze, an effect is a reactive effect in the original sense. It’s the older meaning. The one that was always there, before React reused the word for something else.

Same job — fetch when an id changes — written in both:

// React. You know this code.
function Profile({ id }: { id: string }) {
const [user, setUser] = useState<User | null>(null)
useEffect(() => {
const ctrl = new AbortController()
let cancelled = false
fetch(`/u/${id}`, { signal: ctrl.signal })
.then(r => r.json())
.then(u => { if (!cancelled) setUser(u) })
return () => { cancelled = true; ctrl.abort() }
}, [id]) // forget id and you ship a stale-fetch bug
return <div>{user?.name}</div>
}
// Phaze
const id = signal('123')
const user = signal.async(() =>
fetch(`/u/${id()}`, { signal: abortSignal() }).then(r => r.json())
)
function Profile() {
return <div>{user.value()?.name}</div>
}

signal.async() is a thin wrapper over effect(). Reading id() inside the body subscribes to it — so the runtime knows when to re-run without you maintaining a list. abortSignal() returns a signal the runtime aborts automatically when the effect re-runs or disposes. There’s no cancelled flag because there’s no race to lose.

The lower-level version, if you don’t want signal.async:

const id = signal('123')
const user = signal<User | null>(null)
effect(() => {
fetch(`/u/${id()}`, { signal: abortSignal() })
.then(r => r.json())
.then(u => user.set(u))
})

The id() call is the subscription. You can’t forget it because reading the value is how the runtime registers the dep.

A walk through the useEffect failure modes you’ve internalized — and what replaces them.

In React, count inside an effect body is whatever it was on the render that produced that closure. If your dep array doesn’t include it, you’re reading old state forever. Dan Abramov’s useInterval post exists because this bug is so common it needed a name.

In Phaze, count() is a function call that reads from the live signal store. There is no closed-over value to go stale. The famous broken interval:

// React, broken
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 100)
return () => clearInterval(id)
}, []) // count is captured stale; ticks always set it to 1
// Phaze, just works
effect(() => {
const id = setInterval(() => count.set(count.current() + 1), 100)
cleanup(() => clearInterval(id))
})

The whole reason react-hooks/exhaustive-deps exists is that the dep array is a manual register the compiler can’t fully verify. You list what should re-trigger the effect. Forget one, ship a bug.

In Phaze, the body subscribes to whatever it reads. The list is the body. You can’t get it wrong because there’s nothing to enumerate.

useCallback, useMemo, and React.memo exist in significant part to keep the deps array stable across renders. Phaze tracks signal identity, not value identity inside objects. signal({x: 1}) is one signal regardless of how many times you call it. The referential-stability dance has nothing to stabilize against.

The pattern you’ve written a hundred times:

useEffect(() => {
let cancelled = false
fetchUser(id).then(u => { if (!cancelled) setUser(u) })
return () => { cancelled = true }
}, [id])

In Phaze: pass abortSignal() to fetch (or any AbortSignal-aware API). The runtime aborts the previous run’s controller every time the effect re-runs. If the API doesn’t take an AbortSignal, register a cleanup(fn) and you’re done.

You’re not maintaining a flag. You’re not double-checking before calling setUser. The runtime handles it.

The classic newcomer footgun: useEffect reads state X and writes to it, X is in deps, runs forever. In Phaze, an effect that only writes a signal doesn’t re-trigger itself — only effects that read it do. You can still write a deliberate loop, but you have to construct it on purpose.

In dev, your effect runs twice. The official guidance is to make every effect idempotent, which is a tall order for “subscribe to a WebSocket.” It’s a useful discipline but it’s a workaround for a render-model property, not an inherent good.

Phaze has no StrictMode. Effects run once on creation, re-run only on signal change. If you want a check that your cleanup is correct, dispose and recreate explicitly — that’s a one-liner.

useLayoutEffect vs useEffect vs useInsertionEffect

Section titled “useLayoutEffect vs useEffect vs useInsertionEffect”

This conversation has happened on every team. Which one does this measurement go in? Why is the layout flickering? Wait, there’s a third one for CSS-in-JS that I’ve never used?

These exist because React commits at three observable moments (mutation, layout, paint) and you sometimes need a different one. Phaze has no commit phase. Bindings update the DOM directly when their signal fires; effects run on the next microtask. There is no “before paint vs after paint” decision to make because the rendering model is different.

{ x: 1 } is a new reference every render, so React re-fires effects (or memos, or callbacks) that depend on it. You wrap in useMemo. Or you flatten the prop. Or you remember to pass primitives. Or you give up and ship a bug.

Phaze tracks signals, not value identity. A signal is its identity; the value changes underneath. Object signal, primitive signal, doesn’t matter — the subscription is to the signal node, not to whatever it currently holds.

Effects compose because they’re not lifecycle-bound

Section titled “Effects compose because they’re not lifecycle-bound”

useEffect only exists inside a component. To have effects in different lifetimes, you split into more components — and now your component graph is shaped by your effect-lifetime requirements rather than your UI.

Phaze’s effect lives anywhere:

// Module scope — runs once when the module loads, lives forever
const theme = signal('light')
effect(() => {
document.documentElement.dataset.theme = theme()
})
// Inside a component — disposes when the parent scope disposes
function Counter() {
const count = signal(0)
effect(() => {
document.title = `Count: ${count()}`
})
return <button onClick={() => count.set(count.current() + 1)}>{count}</button>
}
// Inside another effect — disposes when the outer effect re-runs
effect(() => {
const id = userId()
effect(() => {
fetch(`/u/${id}/feed`).then(...)
// auto-disposed when userId changes; no manual teardown needed
})
})

The parent-child cascade is automatic. Disposing a parent disposes the children first. Re-running a parent disposes the previous run’s children before re-running the body.

This is the part that takes a minute to feel — you’ve spent so long sculpting components around effect lifecycles that it doesn’t immediately register that the lifecycles are now independent of the components.

The complete list:

  1. A signal it read changes. Auto-tracked.
  2. A signal it read inside untrack() changes. No — untrack reads without subscribing.
  3. A signal it read via signal.current() changes. No — current() reads without tracking.
  4. It’s manually disposed. Never re-runs.
  5. Its parent scope re-runs or disposes. Child is disposed first, doesn’t re-run on its own.

Five rules. No exhaustive-deps. No StrictMode caveats. No layout-vs-paint choice.

Two paths, depending on what the resource is:

// AbortSignal-aware APIs (fetch, addEventListener, etc.)
effect(() => {
fetch(url(), { signal: abortSignal() })
})

The runtime aborts the previous controller on every re-run and on dispose. No teardown to write.

// Anything else (timers, third-party SDKs, manual subscriptions)
effect(() => {
const ws = new WebSocket(url())
ws.addEventListener('message', e => msg.set(e.data))
cleanup(() => ws.close())
})

cleanup(fn) runs before the next re-run and on dispose. You can call it multiple times in one body for unrelated resources — no nesting them in a single returned function the way you’d have to with useEffect’s cleanup-via-return.

useEffect grew the API surface it has — dep arrays, lint rules, three variants, StrictMode pairing, the whole useCallback/useMemo/React.memo ecosystem around it — because React’s render model required it. Pure render functions can’t have side effects, so React needed a sanctioned escape hatch, and that hatch had to manually re-derive everything the render cycle already knew (when state changed, what to clean up, what to skip).

Phaze’s render model is different. There’s no commit phase. There’s no component-level rerun. State changes flow through a graph; effects are nodes in that graph. The runtime knows what depends on what because the dependencies registered themselves on read. The cleanup runs because the runtime knows when to run it.

So effect() doesn’t need to do all the things useEffect does. It does one thing — subscribe a body to the signals it reads — and the rest falls out of that.

useEffect is a hammer that grew handles for everything because the only tool in the box. effect is a hammer that’s just a hammer because the rest of the toolbox already exists.

When you see “effect” in Phaze docs (including the priority chapter), it means the reactive subscription. Not the React lifecycle hook. They share a name and almost nothing else — and once you’ve worked with one, the other will feel like the workaround it always was.