Reactivity
Reactivity is signal-based, but the compiler hides the plumbing. You work with plain variables; updates touch only the DOM nodes that read them.
$state
Declare reactive state with $state. Read and write it like a normal variable.
let count = $state(0); count++; // reassign count = 10; // or set directly
Objects and arrays: reassign, don't mutate
A $state signal's value is replaced, never mutated. Reactivity fires when you reassign the variable (signal.value = … under the hood) — not when you mutate the object it points at. So reassign with a new value to trigger updates:
let todos = $state([]); todos = [...todos, { text: "Ship it" }]; // ✅ new array → re-renders todos = todos.filter((t) => !t.done); // ✅ reassign
Mutating in place changes the data but notifies nothing — the update is silently lost:
todos.push({ text: "Ship it" }); // ❌ no re-render todos[0].done = true; // ❌ no re-render
To catch this, the compiler rejects in-place mutation of $state — array mutators (push, splice, sort, …), Map/Set mutators (set, add, delete, clear), index/property assignment (todos[0] = …, user.name = …), ++/--, and delete — with an error pointing you at the immutable rewrite. Reach for reactive() (below) when you'd rather mutate directly.
Signals track by reference. todos.push(x) keeps the same array reference, so the signal sees no change. todos = [...todos, x] is a new reference, so it notifies — and list rendering still reconciles by key, patching only the one new row.
$derived
$derived creates a cached value that recomputes only when its dependencies change. Pass an expression or a function:
let count = $state(2); const doubled = $derived(count * 2); // 4 → 6 when count is 3 const label = $derived(() => `x${count}`);
$effect
$effect runs a side effect and re-runs whenever any signal it reads changes.
let count = $state(0); $effect(() => { document.title = `Count: ${count}`; });
The compiler tracks values declared with $state. Mutating state held elsewhere won't be observed.
reactive()
When you'd rather mutate directly — deep trees, forms, or anything where the immutable-reassign dance is awkward — use reactive(). It returns a deep, fine-grained proxy over a plain object or array: reading a property inside an effect subscribes to just that path, and writing it notifies only the readers of that path.
import { reactive } from "@opentf/web"; const store = reactive({ todos: [], user: { name: "Ada" } }); store.todos.push({ text: "Ship it" }); // ✅ re-renders — mutation is tracked store.user.name = "Grace"; // ✅ fine-grained: only name's readers update
Unlike $state, reactive() is a runtime primitive (no compiler macro), so you import it and read values directly — no .value, no reassignment ceremony. Use it in a component body, a module singleton (a shared store), or inside a library.
Helpers that ship alongside it:
| Function | Purpose |
|---|---|
reactive(init) | Create a deep reactive store over a plain object/array. |
isReactive(v) | Whether v is a store created by reactive(). |
toRawValue(v) | The underlying plain object (live, not a copy); identity for non-stores. |
snapshot(v) | A plain, non-reactive deep copy — for submit payloads, validation, or persistence. |
Reach for $state for local component state you update by reassignment — it's the lightest primitive. Reach for reactive() when direct mutation of nested data reads more naturally, or when you need a store shared across components. For values scoped to a subtree (theme, layout mode), prefer Context instead of a global store.
Single source of reactivity
All reactive primitives come from @opentf/web. The runtime guards against duplicate copies of the signals engine being bundled, which would silently break tracking.