Skip to content

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”
// React
import { 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>
)
}
// Preact
import { 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>
)
}
// Phaze
import { 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. useState returns 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) vs count.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.

Every one of these transfers one-to-one between the three frameworks:

featureReactPreactPhaze
function componentfunction X(props) { return <div/> }samesame
props destructuringfunction X({ id, name })samesame
children propprops.childrensamesame
event handlersonClick={fn}samesame
inline stylesstyle={{ color: 'red' }} (object)object or stringobject or string
ternary in JSX{cond ? <A/> : <B/>}samesame
&& for conditional{cond && <A/>}samesame
.map(x => <Item/>)yesyesyes (but see <For> for keyed lists)
Fragment<>...</>samesame
refref={ref}samesame — receives the DOM node
spread props<X {...props}/>samesame

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.

patternReactPreactPhaze
class="..." (HTML idiom)✗ — must be className
className="..."
object form class={{ a: true }}✗ — needs clsx / classnames
reactive class via signalre-renderre-renderbinding 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 compiler
import { 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.

htm + Preact:
render → tagged-template parsed → VNode tree built →
diffed against previous tree → DOM patches applied

Every 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 → patches

A 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.

@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 wired
Phaze 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.

htm + PreactPhaze
runtime size (brotli, minified)Preact + @preact/signals5.31 KB, plus htm parser ≈ 0.7 KB → ~6 KBPhaze sub-3 KB, no parser
render allocationsone VNode per element/text nodezero — cloneNode(true) of a pre-parsed template
render workVNode tree built, diffed against previous tree, patches appliedclone + thread reactive bindings to specific child nodes
state changere-run component → re-build VNode tree → diff → DOM mutationsthe binding subscribed to that signal runs → one DOM mutation
GC pressure on hot updateshigh — VNodes are short-lived garbagenone
list update — one cell of 1000 changes1000 VNode allocations, full tree diff, 1 mutation0 allocations, 0 diff, 1 mutation

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.

If you’ve heard about Phaze’s “HTML support,” it’s probably one of these:

  • template(htmlString) — the low-level primitive @madenowhere/phaze-compile emits. Parses an HTML string once via Document.parseHTMLUnsafe(), caches the resulting element, returns a factory that cloneNodes 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 imperative attachShadow() calls.

Neither interpolates values; both are infrastructure for the compiled JSX path, not alternative authoring surfaces.

Worth knowing if you’re coming from React/Preact:

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.)

<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.

patternwhen 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 arrayone-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.

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.

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.

fromtoGod Mode
useState(x)signal(x)s(x)
useState(x) + setter formsignal(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 stablesame
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/catchsame
Portal<Portal> from phaze/portalsame

The biggest mental flip: stop thinking “component re-renders.” Start thinking “components are scaffolding that wires up bindings; signals are the things that update.”