Components

Template refs and ids

useTemplateRef gives client code an element that ref attaches, and useId gives an id that is unique on the page. Every framework gets its own refs and ids.

Some code needs an element itself: to focus it, to measure what it holds, or to clear a field. A template ref gives client code the element. Some markup needs an id that ties two elements together, such as a label and its field. useId gives one that is unique on the page.

Template refs

useTemplateRef<T>() declares a template ref, and ref={field} on an element attaches it. Once the element is in the document, client code reads it as field.value:

SearchToggle.uf.tsx
import { useTemplateRef } from "unframework";

export default function SearchToggle() {
  const field = useTemplateRef<HTMLInputElement>();
  const trigger = useTemplateRef<HTMLButtonElement>();

  function focusField() {
    field.value?.focus();
  }

  function returnFocus(event: KeyboardEvent) {
    if (event.key === "Escape") trigger.value?.focus();
  }

  return (
    <div class="search-toggle" role="search">
      <button type="button" ref={trigger} onClick={focusField}>
        Search
      </button>
      <input name="query" aria-label="Search the docs" ref={field} onKeydown={returnFocus} />
    </div>
  );
}

Each framework gets its own refs:

SearchToggle.tsx
import { type KeyboardEvent, useRef } from "react";

export default function SearchToggle() {
  const field = useRef<HTMLInputElement>(null);
  const trigger = useRef<HTMLButtonElement>(null);

  function focusField() {
    field.current?.focus();
  }

  function returnFocus(event: KeyboardEvent) {
    if (event.key === "Escape") trigger.current?.focus();
  }

  return (
    <div className="search-toggle" role="search">
      <button type="button" ref={trigger} onClick={focusField}>
        Search
      </button>
      <input name="query" aria-label="Search the docs" ref={field} onKeyDown={returnFocus} />
    </div>
  );
}
  • React declares useRef<T>(null), read as field.current.
  • Vue declares useTemplateRef<T>("field"), keyed by the binding's name, and writes ref="field" on the element.
  • Svelte declares a let, T | null and null at first, and binds it with bind:this, which writes null once the element is gone. Where the element is inside a conditional, the let is $state, as Svelte asks.
  • Angular declares viewChild<ElementRef<T>>("field") and writes #field on the element. Code reads this.field()?.nativeElement, and ?? null where it keeps or passes the element.
  • Solid declares a let, T | null and null at first, which the element's ref callback sets, and empties to null when the element is removed.
  • Qwik declares useSignal<T>(), attached with ref={field}. Qwik's signal holds undefined while it is empty, so code that keeps or passes the element reads field.value ?? null.
  • Astro drops the refs with the code that reads them: nothing runs in the browser.

A template ref is empty before its element is mounted, and again once the element is removed, as when a conditional hides it. An empty template ref is null on every target, as its type, T | null, says, so any test of it reads alike: field.value?.focus(), if (el), el !== null. A test narrows it as any nullable value: Angular, which reads it through a call, asserts the read that the test shows present (State).

Every framework renders the element from state, so client code never changes its structure or its text through the ref: textContent, innerHTML, append, remove, replaceWith and the like are UF3028. Nor does it change the classes of an element whose class the template binds, or the style of one whose style it binds: each framework patches what its template binds its own way. A class, a style or an attribute that the template does not bind is the code's own on every target, as an autosizing field's height is:

TaskBoard.uf.tsx
function resize() {
  const field = notes.value;
  if (!field) return;
  const lines = field.value.split("\n").length;
  field.style.height = `${Math.max(lines, 2) * 1.5}em`;
  field.classList.toggle("tall", lines > 3);
}

Its other properties and methods, focus(), scrollTop, a field's value or childElementCount, are fine.

Where to read one

Only client code reads a template ref: a handler, a watcher's callback, a lifecycle hook, or a function they call. A template ref is never a reactive dependency on any target, so a template, a getter, an initial value, a watcher's source or watchEffect that reads one is UF2010. What a watchEffect hands on to run later, such as a frame's callback, may read one. Teardown code reads none (UF2026, Lifecycle).

  • In onMounted, the element is in the document (Lifecycle).
  • After a write that renders the element, await nextTick() first (Lifecycle).
  • In a watcher, add { flush: "post" }, so that it calls back after the DOM updates (UF2018, Effects).

To focus an element when the component mounts, focus it from onMounted through a template ref: autofocus is UF1002, because the targets apply it differently.

Attaching one

One element attaches each template ref, outside every list: in a list, Vue fills a ref with an array of the elements, and the other targets with the last one. A ref whose value is no template ref (a string, as Vue writes it, a callback or another binding), a template ref attached twice, one inside a list and one that no element attaches are UF3027. ref is written in lower case: REF is UF3004, with a fix.

Ids

useId() gives an id that is unique on the page. Use it for for, aria-describedby and the other attributes that refer to an element by its id:

EmailField.uf.tsx
import { useId } from "unframework";

export interface EmailFieldProps {
  label: string;
  hint: string;
}

export default function EmailField({ label, hint }: EmailFieldProps) {
  const inputId = useId();
  const hintId = useId();

  return (
    <div class="email-field">
      <label for={inputId}>{label}</label>
      <input id={inputId} name="email" type="email" aria-describedby={hintId} />
      <p id={hintId}>{hint}</p>
    </div>
  );
}
EmailField.tsx
import { useId } from "react";

export interface EmailFieldProps {
  label: string;
  hint: string;
}

export default function EmailField({ label, hint }: EmailFieldProps) {
  const inputId = `uf-id-${useId()}`;
  const hintId = `uf-id-${useId()}`;

  return (
    <div className="email-field">
      <label htmlFor={inputId}>{label}</label>
      <input id={inputId} name="email" type="email" aria-describedby={hintId} />
      <p id={hintId}>{hint}</p>
    </div>
  );
}
  • React and Vue call their useId(), and Solid createUniqueId().
  • Qwik calls useId() outside a template literal, "uf-id-" + useId(), which its lint asks for.
  • Svelte calls $props.id(), which a component may call once, and derives every id from it with a suffix of its own: uf-id-${uid}-0, uf-id-${uid}-1. So ${id}-1 of one id never spells another.
  • Angular has no id API, so a module counter makes each instance's ids, with the component's name in them, as Angular Material makes its own (use-id, emulated).
  • Astro counts on Astro.locals, which lives for one request: two instances on a page get different ids, and every request starts again at 1 (use-id, emulated).

Every id starts with uf-id-, followed by what the framework makes, which differs from target to target. So use an id only to tie elements together: never parse it, compare it or show it. An id may name a radio group, and give each row an id of its own:

SizePicker.uf.tsx
{sizes.map((size, index) => (
  <div key={size} class="size">
    <input
      type="radio"
      id={`${group}-${index}`}
      name={group}
      value={size}
      onChange={() => choose(size)}
    />
    <label for={`${group}-${index}`}>{size}</label>
  </div>
))}

An id is bound by a const at the top level of the setup (UF2006), and read as a string, in the template as in code. An id written by hand never starts with uf-id- (UF3005): the compiler's ids own the prefix.

Not yet

  • A template ref on a child component, and defineExpose, land with composition in M3.
  • Ids that match between the server's HTML and the browser that hydrates it land with hydration in M8: Solid's server and browser make different ids, and Angular's counter counts on each side apart.

The decision behind this page is ADR-0049.

Copyright © 2026