Templating
JSX is standard — use ordinary JavaScript for control flow. The compiler turns dynamic expressions into surgical DOM updates anchored by hidden comment nodes.
Conditionals
Ternaries and && work inline in JSX and stay reactive when they can produce DOM nodes:
{isLoading ? <Spinner /> : <Content />} {error && <p class="error">{error}</p>}
The compiler wraps these inline holes in a dynamic region: a comment anchor plus a bindChild effect that swaps the rendered branch when the condition changes — without rebuilding the parent.
Init-once vs reactive conditionals
A component body runs once when the element first connects (connectedCallback). Local variables, if statements, and JSX assigned to const/let are evaluated in that single pass. Only regions the compiler wires to signals — inline JSX holes, text holes, lists, props — keep updating afterward.
Think of it as two layers:
| Layer | When it runs | What updates later |
|---|---|---|
| Component body | Once at first connect | Nothing — unless you read signals inside an $effect |
| Inline JSX holes | Built once, then managed by effects | {cond ? <A/> : <B/>}, {text}, {items.map(…)} |
Reactive — use these for show/hide and layout switches
Put the conditional inside JSX (or return a conditional expression whose branches are JSX). Signal reads in the expression subscribe the region:
Root conditional with JSX branches:
export default function Dashboard() { let user = $state(null); return user ? <MainApp user={user} /> : <Login />; }
Stable outer shell, reactive inner hole:
export default function Dashboard() { let user = $state(null); return ( <div class="shell"> {user ? <MainApp user={user} /> : <Login />} </div> ); }
&& short-circuit holes behave the same way — a falsy condition removes the branch; truthy mounts it again on the next change:
{isAdmin && <AdminPanel />}
Init-once — frozen at first connect
These patterns read signals once during initialization. Changing the signal later does not swap the UI.
Early return guard — frozen at first connect:
export default function Dashboard() { let user = $state(null); if (!user) return <Login />; // ❌ never flips when user logs in return <MainApp user={user} />; }
JSX cached in a local — builders run once, branch is fixed:
export default function Dashboard() { let user = $state(null); const panel = user ? <MainApp user={user} /> : <Login />; // ❌ evaluated once return <div>{panel}</div>; // `panel` is a plain value, not a reactive hole }
Separate top-level return paths — the compiler builds the view from a single return expression; guard returns in if blocks do not become a reactive switch:
export default function Dashboard() { let user = $state(null); if (user) { return <MainApp user={user} />; // ❌ not a reactive region } return <Login />; }
If the conditional must react to $state, write it as an inline JSX hole or as a single return whose branches are JSX (return cond ? <A/> : <B/>). Do not guard with if (…) return … or cache JSX in a variable.
When a shell (header, nav) should stay put while an inner region swaps, keep the outer nodes static and put the conditional on the inner hole: return <div class="shell">{page ? <Page /> : <Login />}</div>.
Lists
Render arrays with .map. Give each item a stable key so updates reorder nodes instead of rebuilding them:
<ul> {todos.map((todo) => ( <li key={todo.id}>{todo.text}</li> ))} </ul>
With keys, reordering a list moves the existing DOM nodes (longest-increasing-subsequence reconciliation) rather than re-creating them.
Refs
$ref() gives you a handle to a DOM node. Read it after mount:
export default function Field() { const input = $ref(); onMount(() => input.focus()); return <input ref={input} />; }
A $ref is a reactive signal whose value is the element (or null before it mounts). Reading it inside $effect subscribes to it, so the effect re-runs whenever the node changes — e.g. when a conditional swaps <input> for <textarea>:
const el = $ref(); $effect(() => { if (el) measure(el); }); // re-measures when the node changes
No callback refs — use $ref + lifecycle instead
ref takes a $ref, not a function. A function-valued ref (ref={(el) => …}, React's "callback ref") is rejected by the compiler, because everything a callback ref does is expressed more directly with $ref plus effects, lifecycle, and $expose:
| You want to… | Do this |
|---|---|
| Access / focus / scroll a node | $ref + onMount (or an event handler) |
| Measure a node, re-measure when it changes | $ref read inside $effect |
| Initialize a library on a node, tear it down | onMount(() => { const x = init(el); return () => x.destroy(); }) |
| Clean up when the element leaves | onCleanup(() => …) |
| Give a parent an imperative handle | $expose — expose an API, not the raw node |
| Manage a dynamic set of elements (focus/scroll item N) | Make each item its own component that owns its $ref and $exposes what the parent needs |
// Initialize + tear down a third-party editor on the element: export default function Editor() { const el = $ref(); onMount(() => { const cm = new CodeMirror(el); return () => cm.destroy(); }); return <div ref={el} />; }
A $ref is reactive and the framework has explicit lifecycle hooks, so a callback ref adds no capability — only a second way to receive a node. Exposing an API from a child (via $expose) is also a stronger boundary than handing out its DOM. See the full Imperative API guide.