Reactive Macros

Macros are compiler globals. You never import them — the compiler rewrites them and injects the runtime.

MacroSignaturePurpose
$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.
JSX
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.

Bindingref 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)
DefaultWhen no provider wraps the consumer, the context's createContext default is used
OverridesA nested provider replaces the value for its subtree only
JSX
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
WhereComponents only (not pages or layouts)
Compiler outputObject.assign(this, api) inside connectedCallback
Consumption<Child ref={child} /> then child.methodName()
JSX
// 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).

HookRuns
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.

Why no .value

The compiler tracks macro variables statically, so reads and writes are plain variable access — the reactivity is wired at build time.

Optional imports

@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.