let and const — the compiler transforms them into signals and computed values. When data changes, only the specific DOM nodes that depend on it are updated.
State with let
Declare local state with let. The compiler transforms it into a signal.
let count = 0 becomes const count = signal(0), and count++ becomes count.value++. You never see or write this — the compiler handles it.
What the compiler transforms
| You write | Compiler produces |
|---|---|
let count = 0 | const count = signal(0) |
count++ | count.value++ |
count = 10 | count.value = 10 |
{count} in JSX | Reactive text node reading count.value |
Union-typed state
When alet needs a union type (like 'code' | 'spec'), annotate the variable and widen the initializer with as:
Why this is needed
This is standard TypeScript behavior: when you initializelet panel: 'code' | 'spec' = 'code', the compiler’s control-flow analysis narrows panel to the literal type 'code' — because at the next statement, nothing has reassigned it yet. Comparing it to 'spec' then fails with TS2367: This comparison appears to be unintentional because the types '"code"' and '"spec"' have no overlap.
The Vertz compiler rewrites let into a signal, but TypeScript sees your source — not the compiled output — so the narrowing still applies.
The fix: let x: T = v as T
Keeping the variable annotation and adding as T on the initializer tells TypeScript: “this is the declared type, not the literal.” The compiler keeps the signal it generates, the runtime value is unchanged, and comparisons work as expected.
The oxlint rule vertz-rules/no-narrowing-let flags this pattern in .tsx components and autofixes it.
Derived values with const
Declare derived values with const. If the expression depends on a signal, the compiler wraps it in computed().
totalItems, totalPrice, isEmpty, and summary all depend on items (a signal) and wraps each one in computed(). When items changes, these recompute — and only the DOM nodes that read them update.
Rules for const
- The right side must be an expression (not a function declaration)
- The compiler only wraps it if it detects a reactive dependency
- If there’s no reactive dependency, it stays a plain
const— zero overhead
Effects with watch()
Use watch() to run side effects when a signal changes. This is for side effects only — not for deriving values.
Reactive props
When you pass an expression to a child component, the compiler generates a getter so the child receives a live binding — not a snapshot.Display component runs once. When count changes in the parent, the getter fires and only the <span> text updates. The Display function is never re-executed.
Reactive attributes
Signal-derived expressions work directly in JSX attributes:isActive updates className, aria-pressed, and style — but doesn’t touch disabled.
Batch updates
Multiple signal writes in the same synchronous block are batched automatically:batch():
Context
Share state across components without prop drilling usingcreateContext():