Template 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:
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:
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 asfield.current. - Vue declares
useTemplateRef<T>("field"), keyed by the binding's name, and writesref="field"on the element. - Svelte declares a
let,T | nullandnullat first, and binds it withbind:this, which writesnullonce the element is gone. Where the element is inside a conditional, theletis$state, as Svelte asks. - Angular declares
viewChild<ElementRef<T>>("field")and writes#fieldon the element. Code readsthis.field()?.nativeElement, and?? nullwhere it keeps or passes the element. - Solid declares a
let,T | nullandnullat first, which the element's ref callback sets, and empties tonullwhen the element is removed. - Qwik declares
useSignal<T>(), attached withref={field}. Qwik's signal holdsundefinedwhile it is empty, so code that keeps or passes the element readsfield.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:
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:
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>
);
}
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 SolidcreateUniqueId(). - 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}-1of 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:
{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.
Lifecycle
onMounted runs once the component's DOM is in the document, onUnmounted when it is removed, and nextTick waits for the DOM to update. None of them runs on the server.
Targets
What each of the seven targets writes, the capability matrix that declares where a framework differs, how each target takes a component's events, and how Angular hosts a component.