Components

Expose and component refs

defineExpose lists the functions a parent may call, and a template ref on a component holds them. Each framework exposes them its own way, from React's imperative handle to Angular's public methods.

Sometimes a parent needs a child to act now, rather than when a prop changes: focus the search field, reset a counter, scroll a list to its end. A component lists what a parent may call with defineExpose, and the parent holds the component with a template ref, as it would hold an element (Template refs), and calls what it exposes from client code.

Exposing functions

defineExpose takes an object of the component's local functions, each in shorthand:

SearchField.uf.tsx
import { defineExpose, useTemplateRef } from "unframework";

export default function SearchField({ label }: { label: string }) {
  const input = useTemplateRef<HTMLInputElement>();
  function focus() {
    input.value?.focus();
  }
  defineExpose({ focus });
  return (
    <label class="search">
      {label}
      <input ref={input} type="search" />
    </label>
  );
}
Header.uf.tsx
import { useTemplateRef } from "unframework";

import SearchField from "./SearchField.uf.tsx";

export default function Header() {
  const search = useTemplateRef<{ focus(): void }>();
  return (
    <header class="header">
      <SearchField ref={search} label="Find" />
      <button type="button" onClick={() => search.value?.focus()}>
        Search
      </button>
    </header>
  );
}

The parent declares the ref with an object type of what it calls, useTemplateRef<{ focus(): void }>(), attaches it with ref={search}, and calls it in a handler, search.value?.focus(). The ref is null until the child is mounted, so the call reads it with ?.:

SearchField.tsx
import { type CSSProperties, type Ref, useImperativeHandle, useRef } from "react";

export interface SearchFieldHandle {
  focus(): void;
}

export interface SearchFieldAttributes {
  className?: string;
  style?: CSSProperties;
  ref?: Ref<SearchFieldHandle>;
}

export default function SearchField({
  label,
  className,
  style,
  ref,
}: { label: string } & SearchFieldAttributes) {
  const input = useRef<HTMLInputElement>(null);

  function focus() {
    input.current?.focus();
  }

  useImperativeHandle(ref, () => ({ focus }));

  return (
    <label className={cx("search", className)} style={style}>
      {label}
      <input ref={input} type="search" />
    </label>
  );
}

/** Joins class names, and the keys of an object's truthy entries, into one `className`. */
function cx(...parts: unknown[]): string {
  const names: string[] = [];
  for (const part of parts) {
    if (typeof part === "string") {
      if (part) names.push(part);
    } else if (part && typeof part === "object") {
      for (const [key, on] of Object.entries(part)) {
        if (on) names.push(key);
      }
    }
  }
  return names.join(" ");
}
Header.tsx
import SearchField from "./SearchField";
import { type CSSProperties, useRef } from "react";

export interface HeaderAttributes {
  className?: string;
  style?: CSSProperties;
}

export default function Header({ className, style }: HeaderAttributes) {
  const search = useRef<{ focus(): void }>(null);

  return (
    <header className={cx("header", className)} style={style}>
      <SearchField ref={search} label="Find" />
      <button type="button" onClick={() => search.current?.focus()}>
        Search
      </button>
    </header>
  );
}

/** Joins class names, and the keys of an object's truthy entries, into one `className`. */
function cx(...parts: unknown[]): string {
  const names: string[] = [];
  for (const part of parts) {
    if (typeof part === "string") {
      if (part) names.push(part);
    } else if (part && typeof part === "object") {
      for (const [key, on] of Object.entries(part)) {
        if (on) names.push(key);
      }
    }
  }
  return names.join(" ");
}
  • React passes the ref as a prop, ref, which the child fills with useImperativeHandle. The child exports the handle's type, SearchFieldHandle, and the parent reads search.current.
  • Vue writes defineExpose, and the parent useTemplateRef("search") with ref="search".
  • Svelte exports each function from the component, export function focus(), and the parent binds a let with bind:this.
  • Angular makes each exposed function a public method, and the rest protected where the template reads them, private otherwise. The parent writes a template reference, #search, and reads it with viewChild.
  • Solid passes a ref callback, which the child calls with what it exposes, and the parent empties its let with onCleanup.
  • Qwik has no component instance to hold, so the parent passes a signal as ref, and the child fills it with its $ functions and empties it when it is removed, through an inline helper, exposeRef (expose, emulated).
  • Astro renders on the server only, so nothing calls what a component exposes: the ref and its calls are dropped (expose, for information).

The argument is an object literal of local functions, by name: no state, no computed value and no function written in place (UF2030). Expose a function that reads or writes the state instead. A ref on a component that exposes nothing, or whose type names a function the component doesn't expose, is UF3046.

Several functions

A component may expose several functions, declared with function or as an arrow function:

Counter.uf.tsx
import { defineExpose, ref } from "unframework";

export default function Counter({ start }: { start: number }) {
  const count = ref(start);
  function increment() {
    count.value++;
  }
  const reset = () => {
    count.value = start;
  };
  defineExpose({ increment, reset });
  return <output class="count">{count.value}</output>;
}

The parent's ref type lists the ones it calls, useTemplateRef<{ increment(): void; reset(): void }>(), and each button calls one.

Call what a child exposes from client code only, as you'd read an element ref: a handler, a watcher's callback or a lifecycle hook (Template refs).

The decisions behind this page are ADR-0049 and ADR-0054.

Copyright © 2026