Design notes: Rejected patterns
Status: Living design document. Captures patterns Phaze deliberately doesn’t ship, with the reasoning behind each rejection. Read this alongside dsl_directive.md. The combined set defines both what Phaze is and what it isn’t — both halves matter.
Audience: New contributors, AI coding assistants (Claude Code), and future-self at design-review time. If you find yourself reaching for one of these patterns, this doc tells you the alternative.
How to read this doc
Section titled “How to read this doc”Each section has the same shape:
- The pattern — what the rejected approach looks like in code
- Why it’s tempting — the legitimate problem it appears to solve
- Why Phaze doesn’t ship it — the reasoning behind the rejection
- What to do instead — the Phaze-shaped alternative
Rejections are decisions, not opinions. They were made deliberately, often after exploring the alternative seriously. Reopening a rejection requires new evidence (a use case the alternative genuinely doesn’t cover), not just preference.
1. Ref callbacks (ref={fn})
Section titled “1. Ref callbacks (ref={fn})”The pattern:
<input ref={el => el?.focus()} />A function passed as ref that receives the DOM element after creation. Standard React idiom, supported by Solid and most JSX-based frameworks.
Why it’s tempting: It’s the most direct way in React-shaped frameworks to access a DOM element from inside a component. Universal, well-known, low ceremony.
Why Phaze doesn’t ship it: Refs are the imperative escape hatch React uses because everything else in React is declarative-only. They embed React’s mental model — “declarative until you can’t be” — into Phaze’s vocabulary. Once ref is sanctioned, every reusable element-level behavior becomes a ref callback or a hook returning a ref. The “no React feel” stance fights this directly.
What to do instead: use: directives. They’re the Phaze-native way to attach element-level behavior:
// Don't:<input ref={el => el?.focus()} />
// Do:<input use:autofocus={true} />The directive receives the element as its first argument. No ref allocation, no callback indirection, no React baggage. Multiple directives compose by stacking on the same element instead of merging refs (which is awkward in any framework). See dsl_directive.md Part 4 for the directive contract.
If you find yourself wanting “give me an element reference so I can do X,” the answer is “write a directive that does X.” The directive is the element-receiver.
2. Lifecycle hooks (onMount, onCleanup, init, dispose, etc.)
Section titled “2. Lifecycle hooks (onMount, onCleanup, init, dispose, etc.)”The pattern:
const card = () => { const count = s(0)
onMount(() => { console.log('mounted') fetchData() })
onCleanup(() => { console.log('unmounted') })
return <div>{count}</div>}Named primitives that run code at specific stages of a component’s lifecycle. React, Solid, Vue, and Svelte all have variations.
Why it’s tempting: It feels structured — there are clear “moments” (mount, unmount, update) and clear hooks for each. Every named lifecycle stage seems to deserve its own primitive.
Why Phaze doesn’t ship it: Phaze’s component model is positional, not named. The component function runs once, on mount. Top-level statements in the function body ARE the mount logic. There’s no separate “moment” called mount because the entire body runs at mount-time. Adding onMount would create the appearance of multiple lifecycle stages where there’s actually only one — and recreate the React mental model the framework deliberately rejects.
What to do instead: Use code position and the existing primitives:
const card = () => { const count = s(0)
console.log('mounted') // top-level = mount fetchData() // top-level = mount
watch(console.log(`count: ${count()}`)) // reactive
cleanup(() => console.log('unmounted')) // teardown
return <div>{count}</div>}| Where you write code | When it runs |
|---|---|
| Top of the component body | Once, on mount |
Inside watch(...) | On mount + every dep change |
Inside c(...) | Lazily, when read; memoized |
Passed to cleanup(fn) | On disposal |
Three places. No named hooks. The primitives that already exist (watch, cleanup) cover everything onMount/onCleanup/useEffect would, with no new vocabulary.
Specifically rejected names: onMount, onUnmount, onCleanup, init, dispose, mount, unmount, useEffect, useLayoutEffect. Don’t reintroduce any of them as aliases.
3. Aggressive auto-wrap in the compiler
Section titled “3. Aggressive auto-wrap in the compiler”The pattern:
A compiler mode where every JSX expression — children and attributes — gets automatically wrapped in a thunk, so users never write phaze() or phaze::
// User writes:<article class={expanded() ? 'card expanded' : 'card'} data-low-stock={lowStock()}> <p>{`$${price()}`} × {qty}</p></article>
// Compiler aggressively wraps everything:<article class={() => expanded() ? 'card expanded' : 'card'} data-low-stock={() => lowStock()}> <p>{() => `$${price()}`} × {qty}</p></article>The user never sees a thunk wrapper; reactivity “just works.”
Why it’s tempting: It removes a learning curve. Users write JSX as if expressions were eager, and the compiler magically makes everything reactive. Vue and Svelte 5 take versions of this approach and people love the ergonomics.
Why Phaze doesn’t ship it: Phaze’s identity is transparency over magic. The whole framework pitch is “small runtime, predictable mental model, signals + thunks = reactivity you can hold in your head.” Aggressive auto-wrap would create a layer where users write reactive code without ever seeing the thunk — which means they never internalize why their code is reactive. When something doesn’t update (and it will: current(), a stale closure, a forgotten signal call), the debugging instinct breaks down because the magic obscures the mechanism.
There’s also a small performance cost: aggressive mode wraps static-after-evaluation expressions like {count.current()} in unnecessary thunks, paying for an idle Computation per such expression. Minor, but real.
What to do instead: Conservative auto-wrap (which is shipped in phaze-compile 0.0.4) plus explicit phaze: and phaze() for the cases the conservative pass doesn’t reach.
The conservative pass auto-wraps:
- Component children
- JSX-valued props on components
- Host element children with control-flow shapes (ternary,
&&, JSX)
For everything else (arithmetic, template strings, method calls in children; any non-bare-signal in attributes), use phaze() or phaze: explicitly. The wrapper is the user’s signal that this expression participates in reactivity — and that visibility is a feature, not a friction.
4. Q() as the thunk macro name
Section titled “4. Q() as the thunk macro name”The pattern:
An earlier proposal used Q(expr) as the compiler-recognized thunk wrapper:
import { s, c, Q } from '@madenowhere/phaze/dsl'
<article class={Q(expanded() ? 'card expanded' : 'card')}> <p>{Q(`$${price()}`)}</p></article>Q was a single-character sigil meaning “wrap this in a thunk.”
Why it was tempting: Maximally terse. Visually distinct in JSX. Explicit that something special is happening.
Why Phaze doesn’t ship it: The framework’s own name should be the verb. Using a generic single-character marker hides which framework is doing the work; using phaze makes every reactive expression in JSX explicitly Phaze code. It’s a brand decision that doubles as a teaching one — users see phaze repeatedly and learn that this is the framework’s reactivity marker.
What to do instead: phaze() for the function form, phaze: for the namespace form. Same mechanics as Q had; better name.
import { s, c, phaze } from '@madenowhere/phaze/dsl'
<article phaze:class={expanded() ? 'card expanded' : 'card'}> <p>{phaze(`$${price()}`)}</p></article>If you encounter Q in old commits, issues, or discussions, it’s the previous name for what’s now phaze. The mechanism didn’t change; the name did.
5. bind: JSX namespace (two-way binding) — reconsidered with minimal scope
Section titled “5. bind: JSX namespace (two-way binding) — reconsidered with minimal scope”Status: Originally rejected (full Svelte-compatible scope); now shipped in a deliberately narrow form. The spec is in class_bind_implementation.md.
The original rejection:
A namespace for two-way binding between an input and a signal, à la Svelte:
<input bind:value={name} /><input type="checkbox" bind:checked={isActive} />The full Svelte-compatible variant would need to cover many input types (number, date, time, color, file, radio, select, …) each with its own coercion and event semantics, plus IME composition timing, focus/cursor preservation, controlled-vs-uncontrolled interactions, equality semantics for change detection, and bind:group / bind:files / bind:innerHTML / bind:clientWidth and friends. Half-shipping that is worse than not shipping; full-shipping is real engineering work.
Why it was reconsidered: the rejection assumed all-or-nothing. The minimal scope is a third option: ship only the trivial cases (bind:value on text-like inputs + <textarea>, bind:checked on checkboxes) and compile-error every other variant with a pointer at the manual form. That covers ~80% of real-world use without taking on the type-fiddly tail.
What ships:
| Pattern | Element |
|---|---|
bind:value={signal} | <input type="text">, <input type="email">, <input type="password">, <input type="search">, <input type="url">, <input type="tel">, <input> (default), <input type={dynamic}>, <textarea> |
bind:checked={signal} | <input type="checkbox"> |
Anything else (number, date, color, file, radio, select, group, etc.) is a compile-time error with an error message that includes the manual-form alternative.
What’s still rejected:
- The full Svelte-compatible
bind:value(number, date, time, color, file) bind:groupfor radio button groupsbind:files,bind:open,bind:clientWidth,bind:innerHTML, and all property/dimension bindings- Any “enable full bind: at user’s risk” config flag — keep the minimal scope strict to avoid feature creep
use:bind/use:bindValuedirectives that smuggle two-way binding under the directive system. If you find yourself naming a directivebindSomething, that’s a signal you’re trying to ship the rejected feature through a side door.
For the rejected variants, the manual form remains the answer:
<input type="number" value={age} on:input={(e) => age.set(+e.currentTarget.value)}/>
<select value={role} on:change={(e) => role.set(e.currentTarget.value)}>…</select>One extra line per input. Explicit about both directions. No hidden gotchas.
6. once primitive
Section titled “6. once primitive”The pattern:
A reactive primitive that runs an effect exactly once on mount, similar to React’s useEffect(() => ..., []):
once(() => { console.log('runs exactly once on mount') loadInitialData()})Why it’s tempting: It maps cleanly to the React mental model. Users coming from React expect a “run-on-mount” hook.
Why Phaze doesn’t ship it: Top-level statements in a component body already run exactly once on mount, because the component body runs once. There’s no need for a primitive that explicitly runs code “once” because that’s the default for any non-reactive code in the component. Adding once recreates the React lifecycle-hook ceremony for no capability gain.
What to do instead: Just write the code at the top level:
const card = () => { console.log('runs exactly once on mount') // already the default loadInitialData() // also runs once
const count = s(0) return <div>{count}</div>}This connects to the broader rejection of named lifecycle hooks (Section 2). Mount is positional, not a named moment.
7. Synthetic events via on:
Section titled “7. Synthetic events via on:”The pattern:
Treating on:longpress, on:swipe, on:doubletap as native-like events that the runtime synthesizes:
<button on:longpress={fn} on:swipe={fn}>...</button>A registry inside the runtime detects gestures and dispatches synthetic events.
Why it’s tempting: Reads like other events (on:click, on:input). Familiar to React users (synthetic events were a React staple).
Why Phaze doesn’t ship it: A synthetic event system requires a registry of supported events, runtime code for each detection algorithm, and ongoing maintenance as new gestures get added. Every synthetic event ships in the framework regardless of whether you use it. Worse, it conflates two semantic categories: real DOM events (which addEventListener would handle) and framework-synthesized behaviors (which require code to detect). Reading on:foo becomes ambiguous — is foo a real event or framework magic?
What to do instead: use: directives for synthesized interactions. The directive’s body owns the detection logic:
// Don't (synthesized via on:):<button on:longpress={fn} on:swipe={onSwipe}>...</button>
// Do (synthesized via directives):<button use:longpress={fn} use:swipe={onSwipe}>...</button>The clean boundary:
on:event→ native DOM events only (click,input,change,keydown, real CustomEvents from web components, etc.)use:directive→ synthesized interactions and DOM enhancements
If addEventListener('thing', fn) would work on the element, use on:thing. If detecting thing requires multiple events plus state plus thresholds, write a directive.
8. Signal abstraction inside directives (when imperative is clearer)
Section titled “8. Signal abstraction inside directives (when imperative is clearer)”The pattern:
Reaching for signal, effect, JSX, and phaze: bindings inside a directive body when the work is straightforward DOM API code:
// Over-engineered: directive uses signals to position a tooltipexport const tooltip = (el, value) => { const visible = s(false) const top = s(0) const left = s(0)
const tip = ( <div phaze:class={visible() ? 'tooltip visible' : 'tooltip'} phaze:style={`top:${top()}px;left:${left()}px`}> {phaze(value())} </div> ) document.body.appendChild(tip)
// ... event handlers that update signals ...}Why it’s tempting: Phaze gives you reactive primitives, so it feels natural to use them everywhere. “More Phaze idioms = better.”
Why Phaze doesn’t endorse it: Reactive primitives earn their place when something is genuinely reactive — multi-signal computeds, derived values, UI that updates from many sources cascading. For a directive whose entire job is “set up listeners, run handlers, mutate the DOM directly,” reactivity is overhead without benefit. The over-engineered version is more code for the same end behavior.
What to do instead: Imperative DOM code when the work is imperative:
// Right-sized: tooltip directive with imperative DOMexport const tooltip = (el, value) => { let tip = null
const show = () => { tip = document.createElement('div') tip.className = 'tooltip' tip.textContent = value() document.body.appendChild(tip)
const rect = el.getBoundingClientRect() tip.style.position = 'fixed' tip.style.top = `${rect.bottom + 8}px` tip.style.left = `${rect.left}px` } const hide = () => { tip?.remove(); tip = null }
listen(el, 'mouseenter', show) listen(el, 'mouseleave', hide) cleanup(hide)}Reach for signal/effect inside a directive when:
- Multiple inputs combine into derived state
- Multiple bindings inside the directive should react to shared state
- The directive exposes its internal state back to the caller via a passed signal
- Animation states cascade (idle → enter → visible → exit)
For “set up listeners, run handlers, mutate the DOM,” imperative wins.
This is the broader principle: the framework’s primitives are tools, not values. Use them when they fit; don’t use them as identity statements.
9. Wrapper components for element-level behavior
Section titled “9. Wrapper components for element-level behavior”The pattern:
Wrapping behavior in a component that takes children:
const AutofocusInput = (props) => { // somehow get a ref to the underlying input... return <input {...props} />}
const Tooltip = (props) => { // somehow attach to the children... return <>{props.children}<TooltipPortal /></>}
<AutofocusInput> <Tooltip text="Click me"> <button>Send</button> </Tooltip></AutofocusInput>Every behavior becomes a component that wraps something.
Why it’s tempting: It’s the React pattern. Components are the primary mechanism in React/Preact, so behavior gets shaped into components.
Why Phaze doesn’t endorse it: Wrapper components nest, which scales badly. Five behaviors on one element become five layers of indentation, five potential wrapper elements in the DOM, and complex prop-forwarding logic. Phaze has directives precisely to avoid this — directives stack flat, don’t add DOM at the call site, and don’t shadow the underlying element’s identity.
What to do instead: Directives via use:. They compose flat:
// Don't (wrapper components):<TrackingWrapper id="send-btn"> <AutofocusWrapper> <Tooltip text="Click to send"> <button onClick={send}>Send</button> </Tooltip> </AutofocusWrapper></TrackingWrapper>
// Do (directives):<button use:trackImpression={"send-btn"} use:autofocus={true} use:tooltip={"Click to send"} on:click={send}> Send</button>Components are right when the thing has its own structural identity — a card, a dialog, a navigation bar, a data table. Directives are right when it’s pure behavior attached to something that already exists. See dsl_directive.md Part 4 for the full distinction.
10. phaze/gluon as a subpath
Section titled “10. phaze/gluon as a subpath”The pattern:
Bundling the Gluon node-graph library as a subpath of the core phaze package:
import { canvas, port, node } from '@madenowhere/phaze/gluon'Why it was tempting: Brand consolidation — everything Gluon-related under the phaze namespace. Easier to share types. One package to install.
Why Phaze doesn’t ship it that way: Gluon is a substantial library (thousands of LOC at scale), domain-specific (most Phaze apps don’t need it), and will iterate fast in early development. Putting it in core would dilute the “small framework runtime” identity and force every Gluon release to bump the entire phaze package version.
What to do instead: Separate workspace package, peer dependency on phaze:
packages/├── core/ # @madenowhere/phaze (the runtime)├── phaze-compile/├── phaze-directives/ # @madenowhere/phaze-directives└── gluon/ # @madenowhere/gluon (standalone)import { canvas, port, node } from '@madenowhere/gluon'The brand connection is preserved through documentation (README opener, phaze.build/gluon docs path, monorepo placement), not through the package name. This matches the existing pattern (phaze-compile, phaze-astro, phaze-directives) for substantial features.
The Gluon-specific naming choice (@madenowhere/gluon vs @madenowhere/phaze-gluon) is documented in gluon.md. The point here is just: never as a subpath of core.
11. VDOM and reconciliation
Section titled “11. VDOM and reconciliation”The pattern:
A virtual DOM layer where components return descriptions of UI, the framework diffs against the previous tree, and applies patches to the real DOM. React’s central architecture; Preact’s central architecture; what most JSX-based frameworks do.
Why it’s tempting: It’s the dominant pattern in the JS ecosystem. Lots of tooling, lots of mental models built around it. Familiar.
Why Phaze doesn’t ship it: VDOM is a workaround for not having fine-grained reactivity. If you have signals that subscribe to specific DOM mutations, you don’t need to diff anything — you update exactly what changed when it changed. That’s the whole architectural premise. Adding VDOM on top of signals would be paying twice for the same job.
The bundle-size implication is large: React + react-dom is ~50 kB brotli; Phaze is ~1.2 kB brotli for equivalent counter-app functionality. Most of that gap is VDOM.
What to do instead: Nothing. Phaze isn’t VDOM-based and shouldn’t be. JSX in Phaze constructs real DOM nodes directly; bindings (set up by the JSX runtime) update specific DOM properties when their tracked signals change. There’s no diff, no patch, no reconciliation phase. Components run once on mount; reactivity does the rest.
If you find yourself wanting “a way to declaratively re-render a subtree when state changes” — that’s what computeds and effects already do, at the level of individual DOM mutations rather than whole trees. Don’t reintroduce diffing.
Quick reference table
Section titled “Quick reference table”| Rejected pattern | Why | Use instead |
|---|---|---|
ref={fn} callbacks | React-shaped imperative escape hatch | use:directive={value} |
onMount, init, dispose, useEffect | Lifecycle ceremony for stages that don’t exist | Top-level statements + cleanup() |
| Aggressive auto-wrap | Hides reactivity mechanism | Conservative auto-wrap + explicit phaze: / phaze() |
Q() macro | Generic sigil, hides framework identity | phaze() and phaze: |
bind:value / bind:* namespace | Two-way binding has hidden complexity | Manual: value={signal} on:input={...} |
use:bind directive (any name) | Smuggles two-way binding through side door | Same — manual two-way wiring |
once(fn) primitive | Top-level code is already once-only | Just write code at top level |
on:longpress, on:swipe (synthesized) | Conflates real and synthetic events | use:longpress, use:swipe directives |
| Signal abstractions in directives, when unnecessary | Adds machinery for no benefit | Imperative DOM API code |
| Wrapper components for behavior | Nests; doesn’t scale | Directives via use: |
phaze/gluon subpath | Bloats core, couples release cycles | Separate package @madenowhere/gluon |
| Virtual DOM / reconciliation | Workaround for missing fine-grained reactivity | Direct DOM updates via signal subscriptions (default) |
When a rejection should be reconsidered
Section titled “When a rejection should be reconsidered”This doc isn’t permanent law. Rejections were made with the information available at the time. Reopen one if and only if:
- A genuine use case emerges that the alternative doesn’t cover. Not “I miss this from React.” A specific, repeatable problem the rejected pattern solves and nothing else does.
- The framework has matured to where the rejection’s reasoning no longer applies. Some rejections are about staying small in pre-1.0 — once Phaze is 1.0+ and the surface is settled, certain trade-offs may shift.
- The ecosystem has shifted in ways that make the rejection costlier than the alternative. (Unlikely for most of these, but possible.)
Reopen by writing a design proposal that addresses the original rejection reasoning, not just by suggesting “let’s add it back.” The reasoning matters more than the conclusion — if you can defeat the reasoning, the conclusion follows.
What’s NOT in this doc (and why)
Section titled “What’s NOT in this doc (and why)”This doc covers patterns that someone might reasonably suggest that Phaze has decided against. It doesn’t cover:
- Things Phaze never considered — like CSS-in-JS runtimes, GraphQL clients, state machines as first-class primitives. Phaze just doesn’t ship those because they’re orthogonal, not because they were rejected.
- Implementation details — like “we use Map instead of WeakMap for subscribers” or “computeds use a versioning system.” Those are in the runtime source code, not in design docs.
- Style preferences — like indentation, naming conventions for variables. Those go in
.editorconfigand code reviews.
If you’re considering adding a pattern not on this list, check whether it has a decision recorded elsewhere first. If no decision exists, it’s open territory — propose it normally.