Skip to content

Comparisons

Side-by-side recipes for the same task. Phaze has more named primitives but no rules-of-hooks, no virtual-DOM diff, and no class components. The unifying mental model is on Decisions.

Every phaze (dsl) tab uses the DSL subpath + JSX namespaces — the form most apps end up writing in practice. The DSL bundles two distinct readability wins:

  • Terser primitive aliases. s / c / watch for signal / computed / effect, plus the phaze() macro for reactive child expressions. The phaze-compiler auto-thunks their arguments, so c(items.length * 2) reads as the value it represents rather than c(() => items.length * 2). The () => ceremony disappears at every call site.
  • JSX namespaces that move imperative DOM glue out of the component body. phaze:attr={expr} for reactive attributes, on:event={fn} for native events (and on:event={state.set('x')} — bare calls auto-thunk to handlers), use:directive={value} for behavior attachments, class:name={cond} for individual class toggles, and bind:value / bind:checked for two-way input binding. The use: namespace is the load-bearing readability win: behaviors like autofocus on mount, show a tooltip on hover, call this on long-press, call this on click-outside, update this signal when the element scrolls into view would otherwise sit inline as useEffect-shaped boilerplate. Pulling them into directives lets the JSX read as structure-and-state-only while the directive body absorbs the listener wiring, observer setup, and cleanup.

The DSL is purely an authoring convention — each snippet compiles to the same code you’d write against the core API by hand. See the DSL & directives reference for the full vocabulary and the @madenowhere/phaze-directives package for the canonical directive set.

React/Preact’s useState returns [value, setter] and triggers re-renders. Phaze’s signal is a function — read by calling, write via .set(). No re-renders; only the bindings that read this signal update.

// ── Plain /dsl: `.update(prev => next)` ──────────────────────────────
import { s } from '@madenowhere/phaze/dsl'
const count = s(0)
<button on:click={count.update(n => n + 1)}>
{count}
</button>
// ────────────────────────────────────
// ── /dsl + /numeric: `inc(count)` (compiler inlines + drops import) ──
import { s } from '@madenowhere/phaze/dsl'
import { inc } from '@madenowhere/phaze/numeric'
const count = s(0)
<button on:click={inc(count)}>
{count}
</button>

React/Preact: a state change re-runs the whole component. Phaze: only the text node bound to {count} updates. The two phaze forms compile to identical runtime — .update(n => n + 1) and inc(count) both end up as count.set(count() + 1) after phaze-compile’s pass. inc from phaze/numeric is the shorter form when the operation is “increment by 1”; the /numeric import declaration drops out of the compiled bundle entirely (zero shipped bytes). Use .update(prev => …) when the updater needs the previous value for non-trivial logic (clamping, conditional update, etc.).

useMemo recomputes when its dep array changes; computed tracks its own deps automatically and recomputes lazily.

import { c } from '@madenowhere/phaze/dsl'
const total = c(
items().reduce((sum, i) => sum + i.price, 0)
)

No dep array. Phaze’s computed auto-tracks the signals it reads and re-computes only when one changes. The DSL form drops the explicit () => — phaze-compile auto-thunks the argument.

In Phaze, what you write inside useEffect is just effect(). The body runs immediately and on every tracked signal change — no dep array (the runtime tracks the signals you read), no “runs after render” timing model (Phaze doesn’t have renders to run after), no stale-closure trap. Cleanup is via the returned dispose function or cleanup(fn) per-resource inside the body.

For better DX, use watch — the /dsl form that auto-thunks effect(). Same runtime semantics; the difference is purely at the call site: watch(expr) reads as the value-it-represents, where effect(() => expr) always carries the () => ceremony.

useEffect is overloaded for many things; Phaze splits them across purpose-built primitives. The three most common patterns:

Use s.async(loader) — reactive pending / value / error signals, auto-cancel on re-run via abortSignal(). Full story in Reactive Data Signal below.

// phaze (dsl)
import { s } from '@madenowhere/phaze/dsl'
import { abortSignal } from '@madenowhere/phaze'
const data = s.async(
fetch(`/u/${userId()}`, { signal: abortSignal() }).then(r => r.json())
)
// preact / react
useEffect(() => {
const ctrl = new AbortController()
fetch(`/u/${userId}`, { signal: ctrl.signal }).then(...)
return () => ctrl.abort()
}, [userId])

Phaze’s abortSignal() is the load-bearing piece here. The runtime owns one lazy AbortController per active computation; when the effect re-runs (because a tracked signal changed) or disposes (component unmounts), the runtime calls .abort() automatically — before the new run starts. Pass abortSignal() to any AbortSignal-aware API (fetch, addEventListener({ signal }), scheduler.postTask({ signal }), ReadableStream reader, …) and cancellation cascades for free. No manual new AbortController() per run, no cleanup return, no AbortError filtering in a .catch. Computations that never call abortSignal() pay zero AbortController cost — it’s lazy-allocated on first call inside a run.

The footgun the AbortSignal story exists to kill: rapidly-changing inputs (search box, paginated list, dependent fetches) racing slow responses. React’s naive useEffect + fetch pattern lets stale responses arrive after newer ones and overwrite the displayed result. Phaze’s signal.async(loader) is the higher-level wrapper around effect() + abortSignal() — it tracks the same signal reads inside the loader, aborts the previous run on every re-run, and filters aborted resolutions before they touch .pending / .value / .error.

import { s } from '@madenowhere/phaze/dsl'
const query = s('')
const results = s.async(
fetch(`/search?q=${query()}`, { signal: abortSignal() })
.then(r => r.json())
)
// → results.pending() reactive boolean
// → results.value() last successful payload (or undefined)
// → results.error() last error (or null) — never an AbortError

Three real wins Phaze gets for free that React has to author by hand:

  1. Race-safe by construction. query changes → signal.async’s underlying effect re-runs → the runtime aborts the previous controller → the previous fetch’s resolution callback hits if (sig?.aborted) return and drops on the floor. The displayed results.value() can never be a stale response. React’s naive form can race; getting it right requires the if (!ctrl.signal.aborted) guard on every .then / .catch / .finally.
  2. No manual AbortError filtering. Phaze’s signal.async handles it inside the wrapper. In React, you ship an if (err.name !== 'AbortError') filter in every error path — forgetting it means aborts log as user-facing errors.
  3. No dep array, no four-state useState pile, no cleanup return. The loader body reads query() directly; the runtime tracks it. Pending / value / error are reactive signals; React requires four useState calls + a cleanup function + a manual dep array. Forgetting query from the deps is the canonical “stale closure” footgun.

The same mechanism powers any AbortSignal-aware API. abortSignal() passed to element.addEventListener('event', fn, { signal }) removes the listener when the effect disposes — no removeEventListener call needed; works with capture, options, and all. Passed to scheduler.postTask(fn, { signal }), a queued microtask cancels before it runs if the effect re-runs first.

Same JSX in both. In Phaze, loggedIn() is a signal call so the expression auto-tracks; the compiler wraps the ternary in a thunk at build time. In React, the parent re-render evaluates the same expression.

{loggedIn()
? <Dashboard/>
: <LoginButton/>}

In Phaze the unrendered branch is fully torn down — its effects dispose, its event listeners abort. State across toggles is fresh. The && form ({cond && <A/>}) works the same way.

Same nested-ternary syntax as React. The compiler makes it reactive.

{loading()
? <Spinner/>
: error()
? <ErrorView err={error()}/>
: data()
? <Page data={data()}/>
: <NotFound/>}

Phaze has no <Switch> / <Match>. Nested ternaries are reactive and read identically to React.

React uses template strings or clsx-style helpers to compose conditional classNames. Phaze’s class:name={cond} namespace toggles a single class additively, so multiple conditions read as independent lines rather than a nested template-literal soup. Each toggle is its own reactive effect.

<article
class="card"
class:fav={isFav()}
class:expanded={expanded()}
class:warn={lowStock()}
class:loading={isLoading()}
/>

Each class: becomes a separate classList.toggle effect in the compiled output — no full-className rewrites, no template-string fragility, no missing-space bugs. Kebab-case class names (class:is-loading={…}) work verbatim.

For text inputs and checkboxes, bind:value / bind:checked collapse the read + write pair into one attribute. The compiler emits the same signal pass-through + listen() call you’d write by hand — minimal scope by design, so unsupported variants (number, date, select, radio group) error at compile time with the manual-form alternative.

<input type="text" bind:value={name} />
<textarea bind:value={bio} />
<input type="checkbox" bind:checked={remember} />

Number inputs, dates, selects, and radio groups deliberately stay on the manual form — the compiler errors with a precise hint about the right shape (+e.currentTarget.value, new Date(...), on:change on <select>, etc.). Half-supported coercion was the reason bind: was originally rejected; the minimal scope is the third option that earns its place. See the DSL & directives reference for the full list of supported and unsupported variants.

Three options. The default <For for:item={items}> inverts to a {() => items().map(...)} arrow at build time — zero bytes shipped, SSR-renders every row. The React-shape .map() works in Phaze directly — the runtime accepts array children — so React/Preact snippets drop in unchanged. Both rebuild on signal change. Add the phaze flag for LIS-based diffs with moveBefore reorders when row identity has to survive: focused inputs mid-type, drag-and-drop, <video> playback, in-flight animations.

// Default — inverts to `{() => todos().map(t => <Todo todo={t}/>)}`.
// 0 bytes shipped, SSR-rendered, rebuilds on change.
<For for:todo={todos}>
<Todo todo={todo}/>
</For>
// ────────────────────────────────────
// `phaze` flag — runtime For with LIS + moveBefore (~900 B brotli).
// Use when browser-native state has to survive reorders.
<For for:todo={todos} phaze>
<Todo key={todo.id} todo={todo}/>
</For>
// ────────────────────────────────────
// React-shape — equivalent rebuild semantics as the default <For>.
{todos.map(todo => (
<Todo key={todo.id} todo={todo}/>
))}

The default <For> and .map() rebuild on every signal change — fine for reactive lists with per-item phaze handlers (on:click, bind:*, etc.) and for lists where per-item state lives in store(...) proxies (proxy identity survives the rebuild, so toggling t.done still fires only .done subscribers). <For phaze> uses moveBefore so a row whose key already exists is moved — preserving browser-native state (focused input text, animation progress, <video> playback) and per-item phaze listeners (each item runs in its own orphan scope, for.ts:62-87). See API › <For> for the full reference.

React relies on top-down re-render: const Comp = ...; return <Comp/> works because the parent re-runs on state change and the reconciler swaps subtrees. Phaze has no re-render — and ships no <Dynamic> component. The signals-native answer is computed() + native control flow.

import { c } from '@madenowhere/phaze/dsl'
const view = c(() => {
const C = isAdmin() ? AdminPanel : UserPanel
return <C user={user}/>
})
return <main>{view}</main>

Solid ships <Dynamic> for the same reason React relies on re-render: their reconciler needs a value-shaped slot to dispatch component swaps through. Phaze has neither a reconciler nor a need for <Dynamic>. computed() is the reactive boundary; JSX inside it swaps subtrees naturally on signal change.

React: createPortal from react-dom. Preact: same. Phaze: opt-in subpath phaze/portal — a component that mounts children into a different DOM target.

import { Portal } from '@madenowhere/phaze/portal'
<Portal mount={document.body}>
<Modal/>
</Portal>

Portal lives in phaze/portal (subpath) so apps that don’t use modals/tooltips don’t pay for it. Phaze doesn’t synthesize event bubbling through a virtual tree, so events bubble through the actual DOM tree — practical for 95% of portal use (modals, toasts).

React/Preact: class component with componentDidCatch. Phaze: opt-in subpath phaze/catch — function component that hooks the runtime’s computation-tree error walk. Same coverage; no class machinery.

import { Catch } from '@madenowhere/phaze/catch'
<Catch
fallback={(err, reset) =>
<Failed err={err} retry={reset}/>
}>
<App/>
</Catch>

Catches construction-time throws and reactive-callback throws. Doesn’t catch event handlers or async — same as React. Named <Catch> (not <Error>) to avoid shadowing the global Error constructor. Lives on a subpath so apps without an error fallback pay nothing — Vite’s overlay handles dev errors better anyway.

React/Preact use createContext. Phaze does not ship one — a module-level signal() already does what context exists for, with no Provider tree, no useContext, and no construction-time gotcha. The import graph IS the scope; the signal IS the subscription.

theme.ts
import { s } from '@madenowhere/phaze/dsl'
export const theme = s<'light' | 'dark'>('light')
// any component, anywhere
import { theme } from './theme'
function Header() {
return <header phaze:class={theme()}>...</header>
}
theme.set('dark')

React’s context ships rerender storms (every consumer rerenders on any change) and forces Provider plumbing for what’s conceptually just a shared variable. Signals make the rerender cost zero (only bindings that actually read it update) and the Provider plumbing disappears. Module signals replace context, prop-drilling for cross-cutting state, and most state-management libraries.

React: useState + immutable spread, or Zustand / Jotai / Valtio. Preact: same, plus @preact/signals + deepsignal. Phaze: store from phaze/store — opt-in subpath. For the common literal-object case, the compile-time inline-store transform drops the /store import entirely — zero runtime bytes. For dynamic-shape or shallow/$ usage, the runtime fallback adds ~400 bytes brotli.

import { store } from '@madenowhere/phaze/store'
const state = store({
user: { name: 'Anna' },
todos: [],
})
state.user.name = 'Bob' // notifies
state.todos.push(...) // tracks length

Phaze’s store is a Proxy: read-tracks per property, write-notifies per property. Sibling fields don’t re-run when one updates.

  • class, not className — same as Preact, different from React.
  • No key on JSX — Phaze doesn’t diff. Use getKey on <For> instead.
  • {count} binds a signal to a text node — passing a signal directly works, no {count.value}.
  • onClick attaches via AbortSignal — handlers detach automatically on dispose; no manual removeEventListener.