Skip to content

State in Phaze

If you’ve come from React or Preact, you’ve internalized a state-management lattice: useState for local, useReducer for “complex” local, useContext for sharing-without-prop-drilling, plus an ecosystem (Redux / Zustand / Jotai / Recoil) for everything context can’t comfortably handle. Each layer is there because the one below it ran out of road at some scale.

Phaze deletes the lattice. There is one thing, called signal, and you put it where you want it to live.

Effects belong to whichever scope you declared them in

Section titled “Effects belong to whichever scope you declared them in”

The same local/module rule applies to effect() (and watch(), computed(), every reactive primitive):

// Module-scope effect — runs once on module load, lives forever
const theme = signal('light')
effect(() => { document.documentElement.dataset.theme = theme() })
// Component-scope effect — disposed when the component disposes
function Counter() {
const count = signal(0)
effect(() => { document.title = `Count: ${count()}` })
return <button onClick={...}>{count}</button>
}

The mechanism is identical; the lifetime follows the enclosing scope. There is no special “module effect” API and no special “component effect” hook — the same call, written in the right place.

The full argument lives in Decisions; this page is the practitioner’s view of the same idea. The short version: in a signals-native runtime, a module-level signal() already does what context exists for — a value any component can subscribe to, reactively, without prop-drilling. The import graph scopes it. The signal IS the subscription.

theme.ts
export const theme = signal<'light' | 'dark'>('light')
// any component, anywhere — no Provider tree, no useContext, no render-prop
import { theme } from './theme'
function Header() { return <header class={theme}>{/* binds reactively */}</header> }
function Toggle() { return <button onClick={() => theme.set(theme.current() === 'light' ? 'dark' : 'light')}>swap</button> }

No <ThemeProvider> wrapping the tree. No “captured at construction” footnote. No useContext hook to remember to call. This is the same answer for theme, auth, locale, router state, app config, and the long tail of state most React apps use context to share.

What you save in Phaze

Cost in React/PreactCost in Phaze
useState import + [value, setter] destructure per piece of stateconst x = signal(0)
useContext import + createContext factory + <Provider value={...}> wrapping the app + useContext(Ctx) per consumerexport const x = signal(0) + import { x } per consumer
useReducer import + action union + reducer fn + dispatch(...) everywhereconst step = signal<'a' | 'b'>('a'); step.set('b')
A state library (Redux / Zustand) for “outside React” stateAlready outside the component — it’s a module variable
Selector functions + useSelector(state => state.x) to avoid over-subscriptionThe signal IS the selector; only the binding that reads it updates
Provider tree depth (every shared piece of state ≈ one component layer)Zero — modules don’t nest in the JSX tree

The savings aren’t just lines of code. They’re allocations: every React useState produces a tuple object per render, every context consumer subscribes through React’s internal context machinery, every provider re-renders descendants when its value identity changes. A signal is one node in a graph that allocates once and notifies only the bindings that subscribed.

And the savings are mental: there’s nothing to decide. “Is this state local or shared?” → write it inside or outside the function. “Should this be context or a store?” → not a question, because both answers are the same module-level signal().

The signal-as-discriminated-union state machine

Section titled “The signal-as-discriminated-union state machine”

The pattern that comes up over and over in real components: a signal whose type is a union of named states.

const step = signal<'email' | 'code' | 'done'>('email')

That single line is the entire state machine for a wizard-style component (think OTP signup: enter email → enter code → success). Transitions are just step.set(...). Branches in JSX read step().

Union signal: the 80% answer

ApproachProsCons
Multiple boolean signals (isOnEmail, isOnCode, isDone)FamiliarAllows invalid combinations (isOnEmail && isOnCode — what now?). Refactor to a union signal at the first sign of “but what if both are true?”
Union signal (this pattern)Atomic — only one state at a time. Type-safe — step.set('emial') is a compile error. Cheap — one signal node. Reactive — reads in class:hidden / ternaries / phaze(...) rerun automatically.None at this scale.
useReducer-style (action → state fn)Centralized transition logic; easy to enforce “can only go to done from codePhaze doesn’t ship a reducer; you can write const dispatch = (action) => { ... step.set(...) } yourself when you need it. Usually overkill for under ~5 states.
A formal FSM library (XState, Robot)Parallel regions, history, statechart guards, visualizersMassively overkill for a wizard. Worth it when the state diagram is the spec.

The union signal is the 80% answer. Reach past it only when the transitions themselves grow logic that doesn’t fit inline at the call site.

const step = signal<'email' | 'code' | 'done'>('email')
// 1. Show/hide via class — DOM shape stays static, visibility flips reactively
<form class:hidden={step() !== 'email'}></form>
// 2. Transition — just set the next state. No dispatch, no action object.
step.set('code')
// 3. Conditional rendering inside JSX — wrap in phaze() for reactive evaluation
{phaze(step() === 'done' ? '🎉 access granted' : null)}

Of these, pattern 1 is the one to use first. Three panels rendered statically in the DOM, with class:hidden flipping between them, is cheaper and safer than conditionally rendering different JSX shapes — see the compiler chapter on why shape-stable JSX is what the runtime is tuned for.

Naming conventions that keep this scaling cleanly

Section titled “Naming conventions that keep this scaling cleanly”
  • State name = the panel/mode that’s visible, not the action that got there.
    • 'email' (the panel) — 'code', 'done'
    • 'awaiting-email' (too verbose, redundant)
    • 'requesting-code' (mixing tense — better as req.pending() derived from an async signal)
  • Transitions = imperative verbs in the function name, each one ends in a step.set(...):
    const submitEmail = (e: Event) => { e.preventDefault(); void requestCode() }
    const backToEmail = () => { clearCells(); step.set('email') }
  • Derived state goes in computed/c(...), not extra step values. codeValid, position, isPending — derive from state, don’t encode state.

A real component using this pattern (from a Neuralkit early-access form):

function EarlyAccess() {
const step = signal<'email' | 'code' | 'done'>('email')
const email = signal('')
const code = signal('')
const requestCode = async () => {
await api.requestCode({ email: email() })
step.set('code') // transition
}
const verifyCode = async () => {
await api.verifyCode({ email: email(), code: code() })
step.set('done') // transition
}
return (
<div>
<form class:hidden={step() !== 'email'} on:submit={requestCode}>
<input bind:value={email} placeholder="email@" />
</form>
<form class:hidden={step() !== 'code'} on:submit={verifyCode}>
<input bind:value={code} placeholder="6-digit code" />
</form>
<p class:hidden={step() !== 'done'}>Access granted.</p>
</div>
)
}

No reducer. No context. No FSM library. No prop-drilling. Three panels, one signal, three transitions. The DOM shape is constant — only class:hidden flips.

Patterns that lean further into how phaze’s compiler works. They don’t change the model — they just compress the prose of using it. Use them once the basic union-signal pattern feels natural; ignore them until then.

Even less code — signal.is / signal.not (opt-in via phaze/match)

Section titled “Even less code — signal.is / signal.not (opt-in via phaze/match)”

The basic pattern above repeats step() !== 'panel-name' at every visibility check. Once a component has three or more of these, the repetition starts to grate — the union literal appears in signal<...>('...') AND in every !== comparison.

Phaze ships a tiny opt-in subpath — @madenowhere/phaze/match — that fixes both. It exports the helpers in two shapes; pick the one that reads best at your call sites:

// Shape 1 — method form. Signal factory from /match attaches .is/.not on each create.
import { signal } from '@madenowhere/phaze/match'
const step = signal<'email' | 'code' | 'done'>('email')
step.is('email') // method call — receiver-first ("step is email")
step.not('email')
// Shape 2 — free-function form. Helpers take any signal as their first arg.
import { signal } from '@madenowhere/phaze' // or /dsl
import { is, not } from '@madenowhere/phaze/match'
const step = signal<'email' | 'code' | 'done'>('email')
is(step, 'email') // free function — predicate (boolean check)-first
not(step, 'email')

Both forms have identical tracking semantics (same compile-time effect-wrapping, same read() call under the hood). The differences are:

Method form (step.is(val))Free-function form (is(step, val))
Signal must come from/match’s signal factoryanywhere — core, /dsl, computed, any callable
Reads likeEnglish (“step is email”)functional predicate (boolean check) (“is(step, email)“)
Import line namesthe factory (signal)the helpers (is, not)
Autocomplete on step.shows .is / .notno — they’re not on the signal

Why opt-in rather than baked into core: phaze’s runtime ships only what’s actually used. Plenty of phaze code never touches a union-typed signal — those projects shouldn’t pay for methods they never call. Same pattern as phaze/store, phaze/portal, phaze/catch, and the planned phaze/scheduler: one import line buys the feature; not importing costs nothing.

Showing and hiding panels — class:hidden={step.not('X')}

Section titled “Showing and hiding panels — class:hidden={step.not('X')}”

The canonical pattern: render every panel in the DOM, hide the ones that aren’t current. .not composes cleanly with class:hidden because both express the same direction — “the class is applied when the predicate (boolean check) is true.”

<form class="space-y-3" class:hidden={step.not('email')} on:submit={requestCode}></form>
<form class="space-y-3" class:hidden={step.not('code')} on:submit={verifyCode}></form>
<div class="text-right" class:hidden={step.not('done')}>Access granted.</div>

Reads as: “hide this when step is not email.” No double negative — the predicate (boolean check) name is the condition.

Compare to the raw form:

class:hidden={step() !== 'email'} // 32 chars, repeats `step()` and the literal
class:hidden={step.not('email')} // 32 chars too, but reads as a single proposition

Character-count is a wash; mental cost is the real win. The raw form makes the eye unpack three operations (function call, negation, literal); the .not(...) form is one method call with the value as its argument.

.is for everything that isn’t class:hidden

Section titled “.is for everything that isn’t class:hidden”

.is is the positive twin — natural anywhere a boolean predicate (boolean check) fits cleanly:

// Derived signals
const onDone = c(() => step.is('done'))
// Reactive content
{phaze(step.is('done') ? '🎉 access granted' : null)}
// Imperative branches
if (step.is('done')) celebrate()
// Submit handlers gated by step
const submit = (e: Event) => {
e.preventDefault()
if (step.is('email')) void requestCode()
if (step.is('code')) void verifyCode()
}

Use .not for hiding, .is for everything else.

Why this is safe (the compiler does the work)

Section titled “Why this is safe (the compiler does the work)”

Phaze’s class:NAME={expr} is transformed by phaze-compiler to:

effect(() => __el.classList.toggle('NAME', !!expr))

The whole expression is wrapped in effect() at compile time — so any signal reads inside expr register as subscriptions automatically, and the boolean coercion (!!) happens on the result. .is / .not call the underlying signal read, so the subscription registers on every re-evaluation of the effect body — same tracking semantics as if you’d written step() === 'email' inline.

Use .current() directly if you ever need an untracked comparison: step.current() === 'email' reads the value without subscribing the active effect.

The opt-in subpath landed in 0.0.16. On older versions, write the helper in user-land:

src/lib/match.ts
export const is = <T,>(sig: () => T, val: T) => sig() === val
export const not = <T,>(sig: () => T, val: T) => sig() !== val
import { is, not } from '~/lib/match'
// class:hidden={not(step, 'email')}
// {phaze(is(mode, 'edit') ? <Editor/> : <Viewer/>)}

Same compile-time effect-wrapping, same tracked semantics, just one extra argument at the call site. The gotcha: don’t return a function from the helper. const isCode = () => step() === 'code' followed by class:hidden={!isCode} is broken — !function is always false, and the function never runs to read the signal. Helpers return values; the compiler handles the reactivity.

Even less code — array-signal mutations (opt-in via phaze/list)

Section titled “Even less code — array-signal mutations (opt-in via phaze/list)”

A list signal — signal<Todo[]>([…]) — is the second pattern that pays a “ceremony tax” if you write it long-form. Every mutation is signal.update(prev => prev.<arr-method>(...)):

todos.update(prev => prev.filter(t => t.id !== id)) // remove
todos.update(prev => [...prev, newTodo]) // push
todos.update(prev => prev.map(t => t.id === id ? updated : t)) // replace
todos.update(prev => prev.map(t => t.id === id ? { ...t, done: true } : t)) // patch

Each one reads as the implementation (filter, spread, map-ternary) rather than the intent (remove this id, append, replace, partial-update). The t.id !== id form is also a negative match — “keep where NOT equal” — which forces the reader to invert before understanding.

Phaze ships six list helpers from the opt-in phaze/list subpath that compile-strip to the canonical .set/.update form. Same shape as phaze/match’s .is/.not and phaze/numeric’s inc/dec: one subpath import, compile-time inlined, zero runtime bytes for the literal-argument case.

import { s } from '@madenowhere/phaze/dsl'
import { remove, push, replace, patch, matches } from '@madenowhere/phaze/list'
interface Todo { id: string; text: string; done: boolean }
const todos = s<Todo[]>([])
remove(todos, { id }) // "remove the todo with this id"
push(todos, newTodo)
replace(todos, { id }, updated)
patch(todos, { id }, { done: true }) // partial update
// matches: composes with every native array-predicate method —
// useful when you only have the id (URL param, server response,
// delegated-event data-id attr), NOT when you already have the
// item reference from a per-item closure (just pass that directly).
const editing = todos().find(matches({ id: routeParamId }))
todos().some(matches({ done: true })) // any done?

The killer feature is object-shorthand match: { id } reads as “match where every listed property equals.” It’s destructuring shorthand for { id: id } — pairing JS’s existing syntax with phaze’s compile-strip story. Multi-property AND-chains: remove(todos, { id, kind: 'archived' }). Predicate form is still there for non-equality matches: remove(todos, t => t.priority > 5).

matches({ id }) is the predicate-factory form of the same shorthand — matches({ id }) compiles to a plain inline arrow (_t) => _t.id === id, so you can drop it into find / some / every / filter and get the same one-expression DX phaze’s mutation helpers offer.

prepend(sig, item) mirrors push for the front-of-the-array case. See /api/#phazelist for the full transformation table.

Companion to phaze/store, not a replacement

Section titled “Companion to phaze/store, not a replacement”

/dsl’s list helpers mutate the signal — produce a new array reference, fire the signal, let consumers (like <For>) reconcile. phaze/store mutates the proxy.push / .splice / index-assign on the proxied array directly, fires per-property notifies. Use both together: the outer todos is a signal-of-array (use /dsl’s helpers for shape changes); each item inside the array is a store(t) proxy (t.done = !t.done for granular per-property reactivity). See Components › For + per-item stores + closure handlers for the canonical composition.

State architecture best practices. There’s exactly one pattern in phaze — Derived State. The single architectural question within it is whether to extend your signals with store(...) for structured data. The compiler does most of the work; most reactive consumption is just a JSX binding away.

If a component just needs to reflect the global signal — show/hide a panel, gate a button, change a class — you don’t need a derived signal at all. The binding IS the derivation.

// Module-scope global state
const appState = signal<'s1' | 's2'>('s1')
function Panel() {
// This is the entire wiring. No computed, no local signal, no effect.
return <div class:hidden={appState() !== 's2'}></div>
}

class:hidden={…} compiles to effect(() => el.classList.toggle('hidden', !!(…))). The expression reads appState, registers the effect as a subscriber, the class flips on every change. One effect, one subscription, fully reactive — no extra runtime node.

computed(...) (or c(...) from /dsl) buys you three properties a bare expression doesn’t:

  1. Memoization across readers. Multiple bindings share one computation pass per invalidation instead of each re-evaluating independently.
  2. Output-equality gating. If the derivation’s result is Object.is-equal to the last value, subscribers don’t re-run — even though the inputs changed. A bare expression in a binding re-runs on every input change, regardless of whether the result is the same.
  3. A Computed<T> handle. Callable like a signal, with .current() and .subscribe(). Useful when downstream code expects a signal-shaped value (passing to <For>, to a directive, to a helper typed as Signal<T>).

Three cases where one or more of those becomes the right tool:

// (a) Reused across multiple bindings — phaze computed reuse (memoization) wins.
const isReady = c(appState.is('s2') && !data.pending())
<form class:hidden={!isReady()}></form>
<button disabled={!isReady()}></button>
{phaze(isReady() ? <SuccessUI/> : <PendingUI/>)}
// (b) Expensive derivation — single computation pass shared.
const filtered = c(items().filter(item => /* costly check */))
// Both <For each={filtered}> and any other reader see the same cached array.
// (c) Output-equality gating — drops same-result re-emits before they fan out.
const isS1 = c(step.is('s1'))
// step transitions: 's1' → 's2' → 's3'
// A bare class:hidden={step.not('s1')} in 5 panels re-runs all 5 effects on
// BOTH transitions, even though the result of step.not('s1') is true in both
// cases after the first. c(...) wraps the predicate (boolean check), sees its output stays
// `true` after the first transition, and skips the notification — 0 binding
// re-runs on the 's2' → 's3' step.

That third case is subtle but real — when one derivation feeds many bindings AND its inputs change more often than its result does, c(...) drops the redundant re-runs before they fan out.

ScenarioPhaze form
One binding reflects a global signalInlineclass:hidden={global.not('s2')}
Same derivation read in ≥2 bindingsc(global.is('s2')), then reuse
Derivation is expensivec(...) for phaze computed (memoization)
Derivation feeds many bindings AND inputs change more often than resultc(...) for output-equality gating
Code expects a Signal<T>-shaped valuec(...) returns a Computed<T> (signal-shaped)

The bias is inline by default, lift to c(...) when one of the three properties above is doing real work. A c(...) that has exactly one reader and a cheap derivation is pure ceremony — strip it.

store(...) earns its place anywhere you have one logical structure with multiple fields, each rendered in a different JSX position, where fields change independently.

Where per-field granularity pays off

Use caseWhat you’d put in the storeWithout per-field granularity (signal-of-object)
Forms — email, name, subscribe checkbox, any multi-field formform = store({ email: '', name: '', subscribed: false })Typing in the email input re-runs the name input’s binding and the subscribe checkbox’s binding on every keystroke
Dashboard panels — CPU, memory, disk, network metrics each in their own cardmetrics = store({ cpu: 0, memory: 0, disk: 0, network: 0 })CPU updating at 30 Hz re-runs the memory/disk/network bindings every tick, even though their values didn’t change
Game state — health bar, mana bar, position, inventory gridplayer = store({ health: 100, mana: 50, position: { x, y }, inventory: [] })Moving the player re-evaluates the health bar, mana bar, and inventory bindings on every frame
Filter / search panel — text input, date picker, tag chips, sort dropdownfilters = store({ text: '', range: [...], tags: [], sort: 'date' })Typing in the search box re-evaluates every other filter control’s binding on every keystroke
Editor state — cursor, selection, scroll, contenteditor = store({ cursor: 0, selection: null, scroll: 0, content: '' })A cursor move at 60 Hz re-evaluates the content render binding
Tabbed wizard with per-step datastate = store({ step: 'a', a: {...}, b: {...}, c: {...} })Changing step re-evaluates every step’s content binding even though the inactive ones aren’t visible
Data table cell editstable = store({ rows: [[...], [...]] })Editing one cell re-evaluates every cell binding in every row
Real-time presence listpresence = store({ users: { u1: 'online', u2: 'idle' } })One person going online re-runs every other user’s badge binding

The decision in three questions, in order. Stop at the first one that gives a clear answer.

1. Is the data nestable?

A scalar — 'hello', 42, true, null — isn’t nestable. Use signal() and stop here.

An object or array with multiple fields IS nestable. Continue.

2. Do you need reactivity in the child?

“Child” means an individual field within the nested data — form.email, state.user.name, items[3].

  • No — you only ever read or write the whole thing as a unit (config.set({ ...nextConfig })): a plain signal<T>() of the object is enough. Every reader re-runs on every write, which is fine when readers care about the whole.
  • Yes — consumers read individual fields, bind one field to an input, iterate the array via <For>, or mutate one leaf at a time: use store(...).

3. What field-level reactivity buys you (i.e. why “yes” to question 2 is worth the subpath import)

  • Per-field tracking. A write to form.email notifies only readers of form.email — not readers of form.name. With a plain signal<T>() of the object, every reader re-runs on every write.
  • Natural mutation. form.email = 'x' instead of form.set({ ...form.current(), email: 'x' }). Array mutators — .push, .splice, index assignment — just work.
  • Lazy nesting. state.user.preferences.theme auto-subscribes to the theme leaf on first read. Manual signal-per-leaf wiring at depth is tedium that compounds.
  • Field-as-signal. $(store, 'email') hands a single field down as a Signal<T> — useful for child components and directives that expect a signal-shaped value.

If any of those affordances would do real work in your code, store(...) is the answer. If none apply — flat data, whole-replace writes, no consumers reading individual fields — stay with signal().

import { signal } from '@madenowhere/phaze'
import { store } from '@madenowhere/phaze/store'
// Scalar — signal
const count = signal(0)
// Nested with per-field reactivity — store
const form = store({
name: '',
email: '',
subscribe: false,
})

Interop — extract a field as a signal. When you have a store but need to hand one field down as a Signal<T> (for <For each={...}>, for a directive that expects a signal, for a child component with a Signal<T> prop), use $(store, key):

import { store, $ } from '@madenowhere/phaze/store'
const form = store({ name: '', email: '' })
// Pass the store as a whole:
<NameInput form={form} />
// Or extract a single field as a Signal<string>:
const nameSig = $(form, 'name') // Signal<string>
<NameInput name={nameSig} />

You’re not locked into one shape — the store is the source of truth; $(...) is the signal-shaped API for components that don’t want the whole store.

Cost. phaze/store is an opt-in subpath. The phaze core stays sub-3 KB brotli. For the common casestore({...literal}) with flat shape and direct property access — the compile-time inline-store transform emits per-field signals at the call site and drops the /store import entirely: zero runtime bytes. For dynamic-shape calls, shallow/$ usage, or escape patterns (passing the store as a prop, exporting it, destructuring), the runtime fallback adds ~400 bytes. Not importing /store at all costs nothing. For the API surface — store(), shallow(), $(), what’s tracked, untrack for one-shot untracked reads — see phaze/store.

A reasonable question coming from React, where the canonical answer is the key-reset pattern — change a key prop on a component, force a remount, the new instance starts with fresh useState defaults.

React relies on key-reset because state lives in useState hooks tied to component identity, and the only way to get a fresh useState initial value is to make React see a new component instance. The pitfalls are real:

  • Nuclear, not surgical. Resets ALL state in the subtree — not just the field you wanted. Every nested useState, every focused input, every uncontrolled child.
  • DOM identity is destroyed. Nodes are torn down and recreated. CSS transitions restart from frame 0; <video> / <iframe> / canvas state vanishes; focus is lost.
  • Every effect re-fires. useEffect cleanup + mount cycle on every key change. Network requests re-issued, subscriptions re-established, timers re-set up.
  • No partial granularity — the whole keyed subtree resets or none of it does.
  • Couples lifecycle to data shape. The data model now drives component lifetime, leading to “why does my form keep resetting” bugs when a parent re-derives the key inadvertently.

In Phaze there’s nothing to “reset” — because the word “reset” assumes a privileged initial state, and signals don’t have one. A signal’s first value is just the value you happened to pass at creation. Writing name.set('') later is the same operation as writing name.set('hello') — both are plain writes. There’s no remount, no destroy-recreate cycle, no “back to factory defaults” branch in the runtime. The state machine is your signal’s value at any moment; “initial” is one state among many.

So what’s “reset” in React is just signal.set(value) in phaze. Use cases that look like reset to a React reader:

  • “Clear” buttonname.set(''), email.set(''), etc. Each is a plain write.
  • Logout cascade — write user.set(null); any local UI that should react does so via watch(user() === null && draft.set('')) or similar.
  • Mode change — when the user enters view mode, write editingField.set(null) so any in-progress edit is dropped.
  • Modal close — internal form fields get written to whatever default the next-open should see. Whether that’s the original initial value or something newer is just a write decision.
  • Wizard step backstep.set('email') doesn’t force fresh state; it’s the value-change driving the visible-panel logic via class:hidden.
  • Search clearquery.set('') is structurally identical to query.set('hello'). Both are writes.

When the trigger is itself reactive (a global signal hitting a particular state should “reset” a local signal), the idiom is a one-line watch:

const draft = signal('')
// When the user logs out, clear any in-progress draft.
watch(loggedOut.is(true) && draft.set(''))

This isn’t a named “Reactive Reset” pattern — it’s just watch() doing what watch() always does. The reason it doesn’t deserve a pattern name is that there’s nothing pattern-shaped here: a signal read, a boolean check, a signal write. Three runtime operations the user already knows.

The deeper architectural point: Phaze’s signals decouple state lifetime from component lifetime. React’s key-reset is the workaround for the fact that hooks bind those two together. Phaze doesn’t need a workaround because the binding doesn’t exist.

State libraries exist to paper over re-render models that need them.

useState exists because React’s render function is pure and state has to live somewhere external. useContext exists to share that state without prop-drilling through the render tree. useReducer exists because complex setter logic gets ugly inside the component body. Redux exists because context’s “every consumer re-renders on every value change” doesn’t scale. Zustand and friends exist because Redux’s ceremony is too much.

Every layer of that stack is a workaround for the same root cause: React’s render model doesn’t have an opinion about where data lives, so each library has to invent one.

Phaze has the opinion baked in: data lives in signals, signals are JavaScript values, JavaScript already has lexical scope for everything else. Inside a function = local. Outside a function = shared. There is no need for a special vocabulary because JavaScript already has the vocabulary.

State management is a feature you only need when your render model can’t make up its mind about where state goes. Phaze’s render model makes up its mind: state goes wherever you put the signal. The rest is just JavaScript.