Components
If you’ve written Preact or React, Phaze’s component file shape will look almost identical. JSX, function components, props, children. The differences are below the surface — when components run, how children update, what the framework hands you for state.
Side by side — the same component, three frameworks
Section titled “Side by side — the same component, three frameworks”// Reactimport { useState } from 'react'
export function Counter({ start = 0 }: { start?: number }) { const [count, setCount] = useState(start) return ( <button onClick={() => setCount(c => c + 1)}> Clicked {count} times </button> )}// Preactimport { useState } from 'preact/hooks'
export function Counter({ start = 0 }: { start?: number }) { const [count, setCount] = useState(start) return ( <button onClick={() => setCount(c => c + 1)}> Clicked {count} times </button> )}// Phazeimport { signal } from '@madenowhere/phaze'
export function Counter({ start = 0 }: { start?: number }) { const count = signal(start) return ( <button onClick={() => count.update(n => n + 1)}> Clicked {count} times </button> )}Same shape. Same JSX. Same prop interface. The differences:
- State primitive.
useStatereturns a value + setter; the component re-runs when state changes.signal()returns a function that is the state — call it to read, call.set()to write — and the component never runs again, only the{count}binding updates. - Update API.
setCount(c => c + 1)vscount.update(n => n + 1). Same shape, different name. - Re-render model. React/Preact re-run the function on every state change; Phaze runs the function once, ever.
What’s the same
Section titled “What’s the same”Every one of these transfers one-to-one between the three frameworks:
| feature | React | Preact | Phaze |
|---|---|---|---|
| function component | function X(props) { return <div/> } | same | same |
| props destructuring | function X({ id, name }) | same | same |
children prop | props.children | same | same |
| event handlers | onClick={fn} | same | same |
| inline styles | style={{ color: 'red' }} (object) | object or string | object or string |
| ternary in JSX | {cond ? <A/> : <B/>} | same | same |
&& for conditional | {cond && <A/>} | same | same |
.map(x => <Item/>) | yes | yes | yes (but see <For> for keyed lists) |
| Fragment | <>...</> | same | same |
ref | ref={ref} | same | same — receives the DOM node |
| spread props | <X {...props}/> | same | same |
class vs className
Section titled “class vs className”Phaze accepts both. Internally they route to the same binding:
// All three work, all three behave identically.<div class="card primary" /><div className="card primary" /><div class={isActive() ? 'card active' : 'card'} />Same as Preact (which accepts both since 10.x). React only accepts className.
Phaze’s class binding also supports an object form for conditional classes — same as Vue and Svelte:
<div class={{ card: true, active: isActive(), dim: isDisabled() }} />Keys with truthy values become classes; keys with falsy values are removed. The whole expression is reactive, so toggling isActive updates only those class entries — not the entire className string.
| pattern | React | Preact | Phaze |
|---|---|---|---|
class="..." (HTML idiom) | ✗ — must be className | ✓ | ✓ |
className="..." | ✓ | ✓ | ✓ |
object form class={{ a: true }} | ✗ — needs clsx / classnames | ✗ | ✓ |
| reactive class via signal | re-render | re-render | binding updates in place |
No HTM in Phaze — and the performance angle
Section titled “No HTM in Phaze — and the performance angle”You may have seen htm (Hyperscript Tagged Markup) — a template-literal alternative to JSX, popular in the Preact ecosystem for setups that don’t run a build step:
// htm + Preact, runtime parsing, no compilerimport { html } from 'htm/preact'const Counter = ({ start }) => { const [n, setN] = useState(start) return html`<button onClick=${() => setN(n + 1)}>${n}</button>`}Phaze doesn’t ship an HTM integration. The reason isn’t ergonomic — it’s that HTM’s runtime model is incompatible with what makes Phaze fast.
What HTM costs you on the hot path
Section titled “What HTM costs you on the hot path”htm + Preact: render → tagged-template parsed → VNode tree built → diffed against previous tree → DOM patches appliedEvery render walks that whole pipeline. Even with HTM’s first-call template caching, you still pay:
- VNode tree allocation per render. A
<div><h2/><p/></div>is three VNodes per render. A list of 100 items is ~300+ VNodes per render. - Tree diff against the previous render. Whether the value actually changed or not.
- DOM patches generated from the diff — typically one mutation per changed leaf, but only after walking the entire tree to find them.
Then for state changes:
state change → component re-runs → new VNode tree → diff → patchesA counter incrementing once rebuilds the entire component’s VNode tree to discover that one text node changed. The framework runtime has to allocate, walk, compare, and emit patches every time.
What Phaze does instead
Section titled “What Phaze does instead”@madenowhere/phaze-compile runs at build time. Your JSX:
function Card({ title, body }) { return <div class="card"><h2>{title}</h2><p>{body}</p></div>}…becomes:
const _t = template('<div class="card"><h2></h2><p></p></div>')
function Card({ title, body }) { const root = _t() // cloneNode(true) of a cached element setText(root.children[0], title) // direct binding to the h2's text node setText(root.children[1], body) // direct binding to the p's text node return root}Compare the cost profile:
Phaze per-render: cloneNode(true) of a cached template + N bindings wiredPhaze per state change: signal fires → ONE binding callback runs → ONE DOM mutation (textContent / setAttribute / classList.toggle, depending on the binding kind)No VNode allocation on render. No tree diff on update. No “rebuild the tree to find what changed” — the runtime already knows which binding owns which DOM node, because the compiler wired it directly at mount.
The wins, ledger-style
Section titled “The wins, ledger-style”| htm + Preact | Phaze | |
|---|---|---|
| runtime size (brotli, minified) | Preact + @preact/signals ≈ 5.31 KB, plus htm parser ≈ 0.7 KB → ~6 KB | Phaze sub-3 KB, no parser |
| render allocations | one VNode per element/text node | zero — cloneNode(true) of a pre-parsed template |
| render work | VNode tree built, diffed against previous tree, patches applied | clone + thread reactive bindings to specific child nodes |
| state change | re-run component → re-build VNode tree → diff → DOM mutations | the binding subscribed to that signal runs → one DOM mutation |
| GC pressure on hot updates | high — VNodes are short-lived garbage | none |
| list update — one cell of 1000 changes | 1000 VNode allocations, full tree diff, 1 mutation | 0 allocations, 0 diff, 1 mutation |
Why HTM can’t be bolted on
Section titled “Why HTM can’t be bolted on”The “no VDOM, surgical bindings” architecture only works if the framework knows at compile time which DOM nodes to bind to. HTM gives you a tagged template literal at runtime — the framework only learns the structure when the function is called, by which point the cheapest option is to build a VNode tree and diff it.
Adding an HTM mode to Phaze would require either:
- A second runtime path that parses HTM, builds a VNode tree, and diffs — bringing back exactly the work Phaze deletes.
- A runtime version of the compiler that walks the template and emits bindings — adds parser bytes to every bundle.
Either choice trades the architecture’s main win for a “no build step” workflow. Phaze isn’t optimized for that workflow; HTM-style libraries are.
Adjacent things that are real
Section titled “Adjacent things that are real”If you’ve heard about Phaze’s “HTML support,” it’s probably one of these:
template(htmlString)— the low-level primitive@madenowhere/phaze-compileemits. Parses an HTML string once viaDocument.parseHTMLUnsafe(), caches the resulting element, returns a factory thatcloneNodes it. Exposed for library code; not what you’d write by hand.parseHTMLUnsafe()itself — the browser-native parser Phaze uses. Unlocks declarative shadow DOM inside templates without imperativeattachShadow()calls.
Neither interpolates values; both are infrastructure for the compiled JSX path, not alternative authoring surfaces.
What’s different about the runtime
Section titled “What’s different about the runtime”Worth knowing if you’re coming from React/Preact:
Components run once
Section titled “Components run once”function Profile({ id }: { id: string }) { console.log('Profile mounted') // runs on mount, never again const user = signal<User | null>(null) effect(() => { fetch(`/u/${id}`).then(r => r.json()).then(u => user.set(u)) }) return <div>{() => user()?.name ?? 'loading'}</div>}That console.log fires once when the component is first rendered into the DOM. Even if user.set() is called a thousand times, the component function does not re-execute. Only the binding inside the JSX — the part that reads user() — re-runs to update the text node.
This is the biggest practical adjustment from React/Preact. There’s no “re-render”; there are bindings that update.
Children are reactive callbacks, not snapshots
Section titled “Children are reactive callbacks, not snapshots”In React/Preact:
{showAdvanced && <AdvancedPanel />}This evaluates on every render. If showAdvanced flips, the parent re-runs and re-evaluates this expression.
In Phaze:
{() => showAdvanced() && <AdvancedPanel />}The arrow function makes the expression a reactive callback. Phaze’s runtime sees the function, calls it once, subscribes to whatever signals it reads, and re-runs only that callback when those signals change. The surrounding component never re-runs.
(@madenowhere/phaze-compile will hoist the arrow form for you when it sees a JSX expression that uses signal reads — you don’t always have to write the arrow explicitly.)
Lists: <For> vs .map()
Section titled “Lists: <For> vs .map()”<For> in Phaze is reactive even in SSR — at zero shipped bytes by default. That’s reactive lists at sub-3 KB. Server-renders every row into the HTML for SEO and first-paint, hydrates them in place on the client, re-renders on signal change. phaze-compile inverts the default <For for:item={items}> to {() => items().map(item => …)} at build time and drops the For import from the bundle.
<For for:todo={todos}> <TodoItem todo={todo}/></For>Add the phaze flag — <For for:todo={todos} phaze> — when row identity has to survive reorders: focused inputs mid-type, drag-and-drop, in-flight animations, <video> playback, or any browser-native state you don’t want destroyed on items.set(...). That opt-in adds the runtime For (~900 B brotli, LIS + moveBefore) to phaze.
| pattern | when to use |
|---|---|
<For for:todo={todos}> (default) | Reactive list, SSR-rendered, 0 bytes shipped. Per-item state survives via store(...) proxies; DOM rebuilds on signal change. |
<For for:todo={todos} phaze> | Browser-native state (focus, typing cursor, video playback) has to survive reorders. Ships the runtime For. |
.map() over a static array | one-time list, never mutates. |
{phaze(signal().map(t => …))} | the literal React shape — equivalent to default <For> for rebuild semantics. |
The canonical action-shaped pattern works under both forms: per-item store(...) proxies + closure handlers + array helpers from phaze/list. See API › <For> for the full reference, including when each form is right.
No useEffect / useMemo / useCallback
Section titled “No useEffect / useMemo / useCallback”Phaze has effect() and computed(). They don’t take dep arrays — auto-tracked. There’s no useCallback because the component runs once; closures are stable. There’s no useMemo because computed values are tracked, not memo-keyed.
See the Effects chapter for the full rationale.
Migration cheatsheet
Section titled “Migration cheatsheet”If you’re porting a Preact or React component to Phaze. to is the @madenowhere/phaze runtime form; God Mode is the DSL authoring layer the compiler rewrites to the same output.
| from | to | God Mode |
|---|---|---|
useState(x) | signal(x) | s(x) |
useState(x) + setter form | signal(x) + .update(fn) | s.set(v) + s.update(fn) |
useMemo(() => ..., [deps]) | computed(() => ...) | c(...) |
useEffect(fn, [deps]) | effect(fn) (drop the deps array) | watch(...) |
useCallback(fn, [deps]) | inline fn — closures are stable | same |
useRef(null) | let ref: HTMLElement (component runs once) | s<HTMLElement>() |
Array.map for keyed lists | <For each={signal}>{(item) => ...}</For> | <For for:item={signal}>…</For> |
cond && <X/> (conditional render) | {() => cond() && <X/>} | {cond() && <X/>} |
cond ? <A/> : <B/> | {() => cond() ? <A/> : <B/>} | {cond() ? <A/> : <B/>} |
<Suspense> | signal.async(loader) + .match({ pending, error, ready }) | s.async(loader) + .match(…) |
<ErrorBoundary> | <Catch> from phaze/catch | same |
Portal | <Portal> from phaze/portal | same |
The biggest mental flip: stop thinking “component re-renders.” Start thinking “components are scaffolding that wires up bindings; signals are the things that update.”