Lifecycle
A component's setup runs before its DOM exists, and on the server too. onMounted runs code once the component's elements are in the document, and onUnmounted once they are removed. In client code, await nextTick() waits until the DOM shows the writes made before it. All three run in the browser only, on every target.
onMounted
onMounted(callback) runs once, after the component's DOM is in the document. It can read an element through a template ref, write state and emit:
import { defineEmits, onMounted, ref, useTemplateRef } from "unframework";
export interface ArticlePreviewProps {
title: string;
text: string;
}
export default function ArticlePreview({ title, text }: ArticlePreviewProps) {
const emit = defineEmits<{ ready: [characters: number] }>();
const body = useTemplateRef<HTMLParagraphElement>();
const characters = ref(0);
const counted = ref(false);
onMounted(() => {
const length = body.value?.textContent?.length ?? 0;
characters.value = length;
counted.value = true;
emit("ready", length);
});
return (
<article class="article-preview" aria-label={title}>
<h2>{title}</h2>
<p ref={body}>{text}</p>
<p role="status">
{counted.value ? `${characters.value} characters` : "Counting the characters"}
</p>
</article>
);
}
Each framework gets its own mount hook:
import { useEffect, useEffectEvent, useRef, useState } from "react";
export interface ArticlePreviewProps {
title: string;
text: string;
}
export interface ArticlePreviewEvents {
onReady?: (characters: number) => void;
}
export default function ArticlePreview({
title,
text,
onReady,
}: ArticlePreviewProps & ArticlePreviewEvents) {
const body = useRef<HTMLParagraphElement>(null);
const [characters, setCharacters] = useState(0);
const charactersRef = useRef(characters);
const [counted, setCounted] = useState(false);
const countedRef = useRef(counted);
const onMount = useEffectEvent(() => {
const length = body.current?.textContent?.length ?? 0;
charactersRef.current = length;
setCharacters(charactersRef.current);
countedRef.current = true;
setCounted(countedRef.current);
onReady?.(length);
});
useEffect(() => {
onMount();
}, []);
return (
<article className="article-preview" aria-label={title}>
<h2>{title}</h2>
<p ref={body}>{text}</p>
<p role="status">{counted ? `${characters} characters` : "Counting the characters"}</p>
</article>
);
}
- React writes an effect with an empty list of dependencies, which runs the callback once, in an effect event.
- Vue writes
onMounted, Svelte and SolidonMount. - Angular writes
afterNextRenderin the constructor: it runs once the view is rendered, in the browser only. - Qwik writes
useVisibleTask$with the"document-ready"strategy, so the task runs once the document is ready, not when the element scrolls into view. - Astro drops the hook, and renders the state it starts with.
A hook never runs on the server, so every target's server render shows the state before it: "Counting the characters". The browser then shows the count. A new value for a prop does not run the hook again.
onMounted and onUnmounted take an arrow function written in place: onMounted(focusField) is UF2022, with a safe fix to onMounted(() => focusField()). They are called at the top level of the setup (UF2005), never inside a condition or another hook.
onUnmounted
onUnmounted(callback) runs once the component is removed. It stops what the component started, such as a timer held in a setup let:
import { defineEmits, onUnmounted, ref } from "unframework";
export interface AutoRefreshProps {
/** Milliseconds between two refreshes. */
interval: number;
}
export default function AutoRefresh({ interval }: AutoRefreshProps) {
const emit = defineEmits<{ refresh: [count: number] }>();
const enabled = ref(false);
let timer: ReturnType<typeof setInterval> | undefined;
let refreshes = 0;
function tick() {
refreshes += 1;
emit("refresh", refreshes);
}
function toggle() {
if (enabled.value) {
clearInterval(timer);
enabled.value = false;
} else {
timer = setInterval(tick, interval);
enabled.value = true;
}
}
onUnmounted(() => {
clearInterval(timer);
});
return (
<section class="auto-refresh" aria-label="Auto-refresh">
<button type="button" aria-pressed={enabled.value} onClick={toggle}>
Auto-refresh
</button>
<p role="status">{enabled.value ? "Refreshing" : "Paused"}</p>
</section>
);
}
import { useEffect, useEffectEvent, useLayoutEffect, useRef, useState } from "react";
export interface AutoRefreshProps {
/** Milliseconds between two refreshes. */
interval: number;
}
export interface AutoRefreshEvents {
onRefresh?: (count: number) => void;
}
export default function AutoRefresh({ interval, onRefresh }: AutoRefreshProps & AutoRefreshEvents) {
const onRefreshRef = useRef(onRefresh);
useLayoutEffect(() => {
onRefreshRef.current = onRefresh;
});
const [enabled, setEnabled] = useState(false);
const enabledRef = useRef(enabled);
const timer = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
const refreshes = useRef(0);
const [tick] = useState(() => () => {
refreshes.current += 1;
onRefreshRef.current?.(refreshes.current);
});
function toggle() {
if (enabledRef.current) {
clearInterval(timer.current);
enabledRef.current = false;
setEnabled(enabledRef.current);
} else {
timer.current = setInterval(tick, interval);
enabledRef.current = true;
setEnabled(enabledRef.current);
}
}
const onUnmount = useEffectEvent(() => {
clearInterval(timer.current);
});
useEffect(() => () => onUnmount(), []);
return (
<section className="auto-refresh" aria-label="Auto-refresh">
<button type="button" aria-pressed={enabled} onClick={toggle}>
Auto-refresh
</button>
<p role="status">{enabled ? "Refreshing" : "Paused"}</p>
</section>
);
}
- React writes an effect whose cleanup runs the callback, declared after every other effect.
- Vue writes
onUnmounted. - Svelte writes
onMountwith a function it returns, neveronDestroy, which Svelte's server runs too, and prints a hook declared before a watcher with a cleanup after it. - Angular writes
ngOnDestroy, under a check that the platform is the browser: Angular destroys a component on the server after it renders. - Solid writes
onMount(() => onCleanup(…)): a plainonCleanupwould also run when Solid's server disposes its render. Solid disposes in the reverse of creation, so the hooks come before the first watcher, in reverse order. - Qwik writes the cleanup of a visible task, registered after every other task.
- Astro drops the hook.
The timer's callback runs later, after renders that may have passed new props. So React reads the props and listeners that deferred code uses through mirror refs, such as onRefreshRef, synced after every render. Angular makes tick an arrow function field, which keeps its this when setInterval calls it, declared before the setup's state and lets, whose initial values could call it. timer and refreshes are setup lets: a useRef on React, a field on Angular, a signal on Qwik (State).
When the component is removed, every watcher's and watchEffect's cleanup runs before the onUnmounted hooks, on every target (Semantics). Teardown code reads no template ref, and does not wait (UF2026): Vue empties a template ref before onUnmounted runs, and React before an effect's cleanup runs, where Svelte, Angular and Qwik still hold the element; and Vue drops an emit once the component is unmounted. Read the element in the callback that sets up the work, and keep it in a local or a setup let.
Listening to the document
A component listens to the document or the window from onMounted, and stops in onUnmounted. A listener that reads a template ref reads it when its event fires, while the component is mounted, so removing it reads nothing:
function onDocumentClick(event: MouseEvent) {
const element = menu.value;
if (open.value && element && !element.contains(event.target as Node)) {
open.value = false;
emit("closed");
}
}
onMounted(() => {
document.addEventListener("click", onDocumentClick);
});
onUnmounted(() => {
document.removeEventListener("click", onDocumentClick);
});
The listener that onUnmounted removes must be the one onMounted added. React declares it once for the instance's life, so it reads state and props through the mirrors:
const [onDocumentClick] = useState(() => (event: MouseEvent) => {
const element = menu.current;
if (openRef.current && element && !element.contains(event.target as Node)) {
openRef.current = false;
setOpen(openRef.current);
onClosedRef.current?.();
}
});
const onMount = useEffectEvent(() => {
document.addEventListener("click", onDocumentClick);
});
useEffect(() => {
onMount();
}, []);
const onUnmount = useEffectEvent(() => {
document.removeEventListener("click", onDocumentClick);
});
useEffect(() => () => onUnmount(), []);
Qwik creates a component's $() functions again each time it renders it, so it holds the listener in a useConstant:
const onDocumentClick = useConstant(() =>
$((event: MouseEvent) => {
const element = menu.value ?? null;
if (open.value && element && !element.contains(event.target as Node)) {
open.value = false;
onClosed$?.();
}
}),
);
useVisibleTask$(
() => {
document.addEventListener("click", onDocumentClick);
},
{ strategy: "document-ready" },
);
useVisibleTask$(
({ cleanup }) => {
cleanup(() => {
document.removeEventListener("click", onDocumentClick);
});
},
{ strategy: "document-ready" },
);
A preventDefault() in a local function that client code adds as a listener runs as the DOM calls it, on every target but Qwik. There a function that reads the component's state is a $() function, which runs once its code has loaded, after the event, so its control is conditional-event-control, an error (Events). A Ctrl+S shortcut on the document that prevents the browser's save has no Qwik output for that reason.
nextTick
A write does not reach the DOM at once on every target. In client code, await nextTick() waits until the DOM shows the writes made before it:
import { defineEmits, nextTick, ref, useTemplateRef } from "unframework";
export default function ShippingDetails() {
const emit = defineEmits<{ toggled: [items: number] }>();
const open = ref(false);
const details = useTemplateRef<HTMLUListElement>();
async function toggle() {
open.value = !open.value;
await nextTick();
emit("toggled", details.value?.childElementCount ?? 0);
}
return (
<section class="shipping-details" aria-label="Shipping">
<button type="button" aria-expanded={open.value} onClick={toggle}>
Shipping details
</button>
{open.value && (
<ul ref={details}>
<li>Ships in two days</li>
<li>Free returns</li>
<li>Tracked delivery</li>
</ul>
)}
</section>
);
}
import { useEffect, useLayoutEffect, useReducer, useRef, useState } from "react";
export interface ShippingDetailsEvents {
onToggled?: (items: number) => void;
}
export default function ShippingDetails({ onToggled }: ShippingDetailsEvents) {
const onToggledRef = useRef(onToggled);
useLayoutEffect(() => {
onToggledRef.current = onToggled;
});
const nextTick = useNextTick();
const [open, setOpen] = useState(false);
const openRef = useRef(open);
const details = useRef<HTMLUListElement>(null);
async function toggle() {
openRef.current = !openRef.current;
setOpen(openRef.current);
await nextTick();
onToggledRef.current?.(details.current?.childElementCount ?? 0);
}
return (
<section className="shipping-details" aria-label="Shipping">
<button type="button" aria-expanded={open} onClick={toggle}>
Shipping details
</button>
{open ? (
<ul ref={details}>
<li>Ships in two days</li>
<li>Free returns</li>
<li>Tracked delivery</li>
</ul>
) : null}
</section>
);
}
/**
* Vue's `nextTick` for one component: the promise resolves once React has rendered the writes
* made before it and run their effects, and `settled` says nothing they wrote waits to render;
* in the task of a click or a key that wrote, as on Vue. It resolves when the component unmounts
* too.
*/
function useNextTick(settled: () => boolean = () => true): () => Promise<void> {
const pending = useRef<{ ticket: number; resolve: () => void }[]>([]);
const tickets = useRef(0);
const mounted = useRef(false);
const [rendered, render] = useReducer(
(last: number, ticket: number) => Math.max(last, ticket),
0,
);
useEffect(() => {
if (!pending.current.length) return;
// Once every effect of the commit has run.
queueMicrotask(() => {
if (!settled()) {
// What the component's effects wrote has yet to render, and may end where it was, when
// React commits nothing: a render of its own makes the next look certain.
tickets.current += 1;
render(tickets.current);
return;
}
const due = pending.current.filter(({ ticket }) => ticket <= rendered);
pending.current = pending.current.filter(({ ticket }) => ticket > rendered);
for (const { resolve } of due) resolve();
});
});
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
queueMicrotask(() => {
if (mounted.current) return;
const due = pending.current;
pending.current = [];
for (const { resolve } of due) resolve();
});
};
}, []);
return () =>
new Promise<void>((resolve) => {
tickets.current += 1;
const ticket = tickets.current;
pending.current.push({ ticket, resolve });
render(ticket);
});
}
- Vue imports
nextTick, and Sveltetick. - React has no
nextTick, so the file declares a hook,useNextTick, that asks for a render of its own and resolves once React has rendered it, run its effects, and rendered what the component's watchers wrote in turn (next-tick, emulated). Where those writes end where they were and React would commit nothing, it asks for one more render. It resolves a pending request when the component unmounts too. The component calls it after the states it checks and before every function that awaits it. - Angular renders a change in a task of its own, so a private
nextTickmethod renders the pending change itself in a microtask,this.appRef.tick(), and resolves after it (next-tick, emulated). - Solid applies a write to the DOM as it makes it, and runs the watchers in a microtask queued at the first write, so its
nextTickhelper resolves a promise after them (next-tick, emulated). - Qwik renders a write's changes in a microtask, so its
nextTickhelper resolves after the next task (next-tick, emulated). Qwik keeps a removed element in its ref, so a template ref in a conditional reads throughrendered, which gives the element while it is in the document, andnullonce it is gone. - Astro drops the handler, and
nextTickwith it.
nextTick is a function, not a macro: client code calls it anywhere, a handler, a watcher's callback, a hook or a local function they call. The setup runs before any DOM exists, so it does not call nextTick (UF2005). It is called bare and awaited: nextTick(callback), and nextTick passed or held as a value, are UF2025, with a safe fix that awaits it where the function can become async. On Vue, Svelte, Solid and Angular the code after await nextTick() runs in the task that wrote, and on React after a click's or a key's handler; elsewhere it may run after the next event (Semantics). An event's preventDefault() after await nextTick() comes too late (UF3033).
The server render
Watchers, watchEffect and lifecycle hooks never change what the server renders. Here onMounted writes the state that a watcher turns into the label:
import { onMounted, ref, watch } from "unframework";
export default function NetworkBadge() {
const online = ref<boolean>();
const label = ref("Checking the connection");
watch(online, (value) => {
label.value = value ? "Online" : "Offline";
});
onMounted(() => {
online.value = navigator.onLine;
});
return (
<p class="network-badge" role="status" data-checked={online.value === undefined ? "no" : "yes"}>
{label.value}
</p>
);
}
Every target's server render reads "Checking the connection", and the browser then shows "Online" or "Offline". Vue runs an immediate watcher's first callback during the setup, on the server too, so that callback is held to what the server can run (UF2013, see Effects). Every other effect and every hook runs in the browser only, on every target (Semantics).
Not yet
- The order of a parent's and its child's hooks lands with child components in M3.
- Vue's other hooks, such as
onBeforeMountandonUpdated, are not part of the language (UF2016): watch the values that change instead. - An
asyncsetup lands in M8.
The decision behind this page is ADR-0048.
Effects
watch calls back when its sources change, with the previous value and a cleanup, and watchEffect runs again when what it reads changes. Effects run in the browser, with Vue's timing, on every target.
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.