Skip to content

Truly reactive UI. No re-renders.

React's name is misleading — it's a re-rendering library. Phaze is what reactive looks like when the runtime takes the name seriously.

Phaze is reactive. React isn't.

One architectural difference. Everything else follows from it.

React rebuilds your component tree on every state change and runs a virtual-DOM diff to figure out what to commit. The work scales with the size of your tree.

Phaze propagates changes through a graph of subscriptions. When a signal changes, only the specific bindings that read it run. The work scales with what actually changed.

The community has been correctly pointing out for years that “React is not reactive.” Phaze is what reactive looks like when the runtime takes the name seriously — and it’s why Phaze ships three flow components instead of seven, why context isn’t needed, and why ternaries Just Work.

Why Phaze exists in three lines

Same JSX. Smaller bundle. Predictable cost.

No re-renders

Reads track at the binding level; writes notify only the bindings that depend on the changed signal. The rest of the tree is idle.

Reactive `fetch` baked in

s.async(fetch(...).then(r => r.json())) returns reactive pending / value / error signals. Re-runs auto-cancel via abortSignal() — race-safe by construction, no client library, no TanStack Query needed.

Sub-3 KB

Full renderer + signals + JSX runtime + flow components ship sub-3 KB brotli. Opt into phaze/store for deep proxies — zero runtime bytes for the common literal-object case (compile-time inlined), ~400 B runtime fallback otherwise.

Control flow lives in JS, not JSX

Three flow components — and only three. Everything else uses native ternaries, &&, ??, and computed().

// Two branches — ternary
{loggedIn() ? <Dashboard/> : <LoginButton/>}
// Show-if-truthy — &&
{showHelp() && <HelpPanel/>}
// Independent show/hide (e.g. tabs) — three && lines
{active() === 'overview' && <Overview/>}
{active() === 'details' && <Details/>}
{active() === 'settings' && <Settings/>}
// Open-ended multi-branch — computed + if-chain
const view = computed(() => {
if (route() === '/home') return <HomePage/>
if (route() === '/profile') return <Profile/>
return <NotFound/>
})
return <main>{view}</main>

No <Show>, <Switch>, <Match>, or <Dynamic>. Phaze’s three named JSX components — <For> in core, <Catch> on phaze/catch, <Portal> on phaze/portal — each do something computed + if can’t. The two opt-in components cost nothing if you don’t import them.

The signals manifesto

Why Phaze has no createContext.

In Phaze, signals are your shared state. Module-scoped signals replace context, prop-drilling, Redux-style stores, and most state-management patterns. The reactive graph IS the wiring.

theme.ts
import { signal } from '@madenowhere/phaze'
export const theme = signal<'light' | 'dark'>('light')
// any component, anywhere
import { theme } from './theme'
function Header() {
return <header class={theme}>...</header>
}
theme.set('dark') // every binding that reads `theme` updates

Reactive Data / API Fetch baked in

No client library. The native Fetch API as a reactive signal — comes free with phaze + signals.

Reactive fetchpending, value, error, auto-cancel on re-run, auto-cleanup on unmount — comes free with Phaze. No client library, no extra dependency. The whole Phaze runtime ships in sub-3 KB brotli, less than half of TanStack Query alone.

One mechanism, every Web API: native fetch, addEventListener, scheduler.postTask — all reactive, all auto-cancel. One abortSignal() handles them all.

import { s } from '@madenowhere/phaze/dsl'
import { abortSignal } from '@madenowhere/phaze'
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
// results.error() last error — never an AbortError

Race-safe by construction: query changes → previous fetch aborts before the new one starts → stale responses can never overwrite results.value(). The same pattern works for any AbortSignal-aware API — pass abortSignal() to addEventListener({ signal }) and the listener detaches on dispose; no removeEventListener call needed.

Get oriented in five minutes

Three pages cover the whole mental model.