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/watchforsignal/computed/effect, plus thephaze()macro for reactive child expressions. The phaze-compiler auto-thunks their arguments, soc(items.length * 2)reads as the value it represents rather thanc(() => 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 (andon:event={state.set('x')}— bare calls auto-thunk to handlers),use:directive={value}for behavior attachments,class:name={cond}for individual class toggles, andbind:value/bind:checkedfor two-way input binding. Theuse: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 asuseEffect-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.
Local state
Section titled “Local state”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>const [count, setCount] = useState(0)
<button onClick={() => setCount(count + 1)}> {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)andinc(count)both end up ascount.set(count() + 1)after phaze-compile’s pass.incfromphaze/numericis the shorter form when the operation is “increment by 1”; the/numericimport 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.).
Derived value
Section titled “Derived value”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))const total = useMemo( () => items.reduce((s, i) => s + i.price, 0), [items])No dep array. Phaze’s
computedauto-tracks the signals it reads and re-computes only when one changes. The DSL form drops the explicit() =>— phaze-compile auto-thunks the argument.
Side Effects (useEffect)
Section titled “Side Effects (useEffect)”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 / reactuseEffect(() => { 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 lazyAbortControllerper 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. PassabortSignal()to anyAbortSignal-aware API (fetch,addEventListener({ signal }),scheduler.postTask({ signal }),ReadableStreamreader, …) and cancellation cascades for free. No manualnew AbortController()per run, no cleanup return, noAbortErrorfiltering in a.catch. Computations that never callabortSignal()pay zeroAbortControllercost — it’s lazy-allocated on first call inside a run.
Use listen(target, type, handler) — auto-removes the listener when the surrounding scope disposes. Works for any EventTarget: Window, Document, HTMLElement, EventSource, MediaQueryList, custom EventTargets.
// phaze (dsl)import { s } from '@madenowhere/phaze/dsl'import { listen } from '@madenowhere/phaze'
const width = s(window.innerWidth)
listen(window, 'resize', () => width.set(window.innerWidth), { passive: true })// No wrapping effect needed — listen() ties to the active component// scope (render's effect) and auto-removes on dispose.// preact / reactconst [width, setWidth] = useState(window.innerWidth)
useEffect(() => { const handler = () => setWidth(window.innerWidth) window.addEventListener('resize', handler, { passive: true }) return () => window.removeEventListener('resize', handler, { passive: true })}, [])No
removeEventListenercall, nohandlerreference to keep around for the cleanup return, no dep array. The listener detaches when the surrounding scope re-runs or disposes. Same shape fordocument.addEventListener('keydown'),mediaQuery.addEventListener('change'), custom EventTargets —listen()figures out the right cleanup composition based on the target.
Use interval(ms, fn) / timeout(ms, fn) from the opt-in @madenowhere/phaze/time subpath (0.53 KB brotli). Auto-cleanup; the callback reads signals via call so no stale-closure bug.
// phaze (dsl)import { s } from '@madenowhere/phaze/dsl'import { interval } from '@madenowhere/phaze/time'import { inc } from '@madenowhere/phaze/numeric'
const count = s(0)
interval(1000, inc(count))// No arrow needed — phaze-compile auto-thunks `interval`'s second arg.// No cleanup return — cleared when the surrounding scope disposes.// Final compiled output:// interval(1000, () => count.set(count() + 1))// (The /numeric import drops out; 0 bytes ship from it.)// preact / reactconst [count, setCount] = useState(0)
useEffect(() => { const id = setInterval(() => setCount(c => c + 1), 1000) return () => clearInterval(id)}, [])Stale-closure trap erased: the callback reads
count()when it fires, so it always sees the current value. React’ssetIntervalexample has the classic stale-closure problem —setCount(c => c + 1)works around it via the updater form, butsetCount(count + 1)silently freezes on the value captured when the effect ran.phaze/timeerases the entire bug class. Bonus:interval(() => ms(), fn)accepts a reactive delay — the timer restarts when the delay signal changes.
Reactive Data Signal
Section titled “Reactive Data Signal”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 AbortErrorconst [query, setQuery] = useState('')const [results, setResults] = useState(null)const [pending, setPending] = useState(false)const [error, setError] = useState(null)
useEffect(() => { const ctrl = new AbortController() setPending(true); setError(null) fetch(`/search?q=${query}`, { signal: ctrl.signal }) .then(r => r.json()) .then(data => { if (!ctrl.signal.aborted) setResults(data) }) .catch(err => { if (err.name !== 'AbortError') setError(err) }) .finally(() => { if (!ctrl.signal.aborted) setPending(false) }) return () => ctrl.abort()}, [query])Three real wins Phaze gets for free that React has to author by hand:
- Race-safe by construction.
querychanges →signal.async’s underlying effect re-runs → the runtime aborts the previous controller → the previous fetch’s resolution callback hitsif (sig?.aborted) returnand drops on the floor. The displayedresults.value()can never be a stale response. React’s naive form can race; getting it right requires theif (!ctrl.signal.aborted)guard on every.then/.catch/.finally.- No manual
AbortErrorfiltering. Phaze’ssignal.asynchandles it inside the wrapper. In React, you ship anif (err.name !== 'AbortError')filter in every error path — forgetting it means aborts log as user-facing errors.- 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 fouruseStatecalls + a cleanup function + a manual dep array. Forgettingqueryfrom the deps is the canonical “stale closure” footgun.The same mechanism powers any
AbortSignal-aware API.abortSignal()passed toelement.addEventListener('event', fn, { signal })removes the listener when the effect disposes — noremoveEventListenercall needed; works with capture, options, and all. Passed toscheduler.postTask(fn, { signal }), a queued microtask cancels before it runs if the effect re-runs first.
Conditional rendering
Section titled “Conditional rendering”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/>}{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.
Multi-branch (loading / error / data)
Section titled “Multi-branch (loading / error / data)”Same nested-ternary syntax as React. The compiler makes it reactive.
{loading() ? <Spinner/> : error() ? <ErrorView err={error()}/> : data() ? <Page data={data()}/> : <NotFound/>}{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.
Conditional classes
Section titled “Conditional classes”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()}/><article className={ `card${isFav ? ' fav' : ''}${expanded ? ' expanded' : ''}` + `${lowStock ? ' warn' : ''}${isLoading ? ' loading' : ''}`} />Each
class:becomes a separateclassList.toggleeffect 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.
Two-way input binding
Section titled “Two-way input binding”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} /><input type="text" value={name} onInput={(e) => setName(e.currentTarget.value)}/><textarea value={bio} onInput={(e) => setBio(e.currentTarget.value)}/><input type="checkbox" checked={remember} onChange={(e) => setRemember(e.currentTarget.checked)}/>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:changeon<select>, etc.). Half-supported coercion was the reasonbind: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.
Reactive lists
Section titled “Reactive lists”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}/>))}{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 instore(...)proxies (proxy identity survives the rebuild, so togglingt.donestill fires only.donesubscribers).<For phaze>usesmoveBeforeso 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.
Dynamic component
Section titled “Dynamic component”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>const Comp = isAdmin ? AdminPanel : UserPanelreturn <Comp user={user}/>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.
Portal
Section titled “Portal”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>import { createPortal } from 'react-dom'
createPortal( <Modal/>, document.body)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).
Error boundary
Section titled “Error boundary”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>class Boundary extends Component { state = { err: null } static getDerivedStateFromError(e) { return { err: e } } render() { return this.state.err ? <Failed err={this.state.err}/> : this.props.children }}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 globalErrorconstructor. Lives on a subpath so apps without an error fallback pay nothing — Vite’s overlay handles dev errors better anyway.
Sharing state without prop-drilling
Section titled “Sharing state without prop-drilling”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.
import { s } from '@madenowhere/phaze/dsl'export const theme = s<'light' | 'dark'>('light')
// any component, anywhereimport { theme } from './theme'
function Header() { return <header phaze:class={theme()}>...</header>}
theme.set('dark')const Theme = createContext('light')
<Theme.Provider value="dark"> <App/></Theme.Provider>
// inside a componentconst theme = useContext(Theme)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.
Deep object state
Section titled “Deep object state”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' // notifiesstate.todos.push(...) // tracks lengthconst [state, setState] = useState({ user: { name: 'Anna' }, todos: [], })
setState(prev => ({ ...prev, user: { ...prev.user, name: 'Bob' },}))Phaze’s
storeis a Proxy: read-tracks per property, write-notifies per property. Sibling fields don’t re-run when one updates.
JSX attributes
Section titled “JSX attributes”class, notclassName— same as Preact, different from React.- No
keyon JSX — Phaze doesn’t diff. UsegetKeyon<For>instead. {count}binds a signal to a text node — passing a signal directly works, no{count.value}.onClickattaches viaAbortSignal— handlers detach automatically on dispose; no manualremoveEventListener.