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 = …), nested ++/--, and delete — with an error pointing you at the immutable rewrite. Updating the signal variable itself with count++ is supported. 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/afw"; 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
Use one resolved copy of @opentf/afw throughout the app. Duplicate copies can create separate signal and router state, so avoid aliases or package links that resolve the same runtime through different paths.