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
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.
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.
A state library (Redux / Zustand) for “outside React” state
Already outside the component — it’s a module variable
Selector functions + useSelector(state => state.x) to avoid over-subscription
The 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().
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().
Allows 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 code”
Phaze doesn’t ship a reducer; you can write const dispatch = (action) => { ... step.set(...) } yourself when you need it. Usually overkill for under ~5 states.
// 1. Show/hide via class — DOM shape stays static, visibility flips reactively
<formclass: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.
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)
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.
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')}
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.”
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.
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.
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)
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>(...)):
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.
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.
/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')
functionPanel() {
// This is the entire wiring. No computed, no local signal, no effect.
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:
Memoization across readers. Multiple bindings share one computation pass per invalidation instead of each re-evaluating independently.
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.
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:
// 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.
Derivation feeds many bindings AND inputs change more often than result
c(...) for output-equality gating
Code expects a Signal<T>-shaped value
c(...) 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 case
What you’d put in the store
Without per-field granularity (signal-of-object)
Forms — email, name, subscribe checkbox, any multi-field form
form = 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 card
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):
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 case — store({...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” button — name.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 back — step.set('email') doesn’t force fresh state; it’s the value-change driving the visible-panel logic via class:hidden.
Search clear — query.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.