Skip to main content
Vertz uses a compiler-driven reactivity model. You write plain 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.
Behind the scenes, 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 writeCompiler produces
let count = 0const count = signal(0)
count++count.value++
count = 10count.value = 10
{count} in JSXReactive text node reading count.value

Union-typed state

When a let 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 initialize let 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().
The compiler sees that 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.
Don’t use watch() to sync derived values — use const instead. The compiler handles the computed wrapper automatically.

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.
The 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:
Each attribute updates independently. Changing 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:
For cases where you need explicit batching across async boundaries, use batch():

Context

Share state across components without prop drilling using createContext():