Reactive Macros
Macros are compiler globals. You never import them — the compiler rewrites them and injects the runtime.
| Macro | Signature | Purpose |
|---|---|---|
$state | $state(initial) | Reactive state. Read/write the variable directly. |
$derived | $derived(expr) | Cached value recomputed when dependencies change. |
$effect | $effect(() => {}) | Side effect that re-runs on dependency change. |
$ref | $ref() | A handle to a DOM node or component instance, bound with ref={…}. |
$context | $context(ctx) | Read the value from the nearest <ContextProvider> for ctx. |
$expose | $expose(obj) | Publish methods on a component instance for the parent to call. |
let count = $state(0); const doubled = $derived(count * 2); $effect(() => console.log(doubled)); const box = $ref(); <div ref={box} />;
$ref
Creates a reactive signal that holds a DOM node or Custom Element instance.
| Binding | ref receives |
|---|---|
Native element (<input ref={el} />) | The HTMLElement |
Component (<Modal ref={modal} />) | The component's Custom Element (with anything $exposed on it) |
The ref is null until the target mounts. Read it in onMount, $effect, or event handlers — not synchronously at the top of the parent before the child exists.
Compiler output: ref={expr} → expr.value = <createdElement>.
$context
Reads a value provided by the nearest ancestor <ContextProvider> for a context token created with createContext. No import — the compiler lowers it to readContext(ctx) and tracks the binding as a signal.
| Signature | $context(Ctx) — Ctx is the token from createContext(default) |
| Provider | <ContextProvider context={Ctx} value={v}>…</ContextProvider> (import ContextProvider from @opentf/web) |
| Default | When no provider wraps the consumer, the context's createContext default is used |
| Overrides | A nested provider replaces the value for its subtree only |
import { ContextProvider, createContext } from "@opentf/web"; const ThemeContext = createContext("dark"); function Card() { const theme = $context(ThemeContext); return <div class={theme}>{theme}</div>; } export default function App() { let theme = $state("dark"); return ( <ContextProvider context={ThemeContext} value={theme}> <Card /> </ContextProvider> ); }
A reactive value (e.g. from $state) updates every consumer when it changes.
Guide with a live demo: Context.
$expose
Publishes an object's properties onto the component's Custom Element instance so a parent can call them through a $ref.
| Signature | $expose(api) — api is a plain object literal or variable holding methods/getters |
| Where | Components only (not pages or layouts) |
| Compiler output | Object.assign(this, api) inside connectedCallback |
| Consumption | <Child ref={child} /> then child.methodName() |
// Child export default function Field() { const input = $ref(); $expose({ focus: () => input.focus() }); return <input ref={input} />; } // Parent export default function Form() { const email = $ref(); return ( <> <button onclick={() => email.focus()}>Edit email</button> <Field ref={email} /> </> ); }
Typical api shape: methods (open, close, focus, reset) and optionally getters. Avoid exposing large state blobs — keep the surface imperative and small.
Not supported: $expose in page.jsx / layout factories (no this to assign to).
Guide with patterns: Imperative API.
Lifecycle hooks
Also global — no import needed. The DOM hooks clean up after themselves: the observer / listener is torn down automatically when the component unmounts (or the router navigates away from the page).
| Hook | Runs |
|---|---|
onMount(fn) | After the component connects to the DOM. Return a function to clean up. |
onCleanup(fn) | When the component is removed. |
onResize(fn) | fn(entry) per ResizeObserver entry for the component's element (one initial entry after mount). |
onVisibilityChange(fn) | fn(isIntersecting, entry) when the component's element enters/leaves the viewport (IntersectionObserver). |
onMediaQuery(query, fn) | fn(matches, event) once immediately at mount with the current matchMedia(query) state, then on every change. |
In a page/layout, onResize / onVisibilityChange require a single element root to observe; onMediaQuery works anywhere. All hooks are recognized only as top-level statements of the component/page body.
Guide with examples: Lifecycle.
The compiler tracks macro variables statically, so reads and writes are plain variable access — the reactivity is wired at build time.
@opentf/web exports no-op stubs for all five hooks for TypeScript and editors. The real behavior is compiler-injected; macros themselves are never imported at runtime.