State
A component's body is its setup: the statements before its return. Every target runs the setup once per instance, in source order. State is a ref, a derived value is a computed, and constants, functions and lets are plain TypeScript. The compiler analyses every statement and lowers it to each framework's own primitive. Anything it cannot lower to every target is a diagnostic, never code copied as it is.
State
ref(initial) declares state. Code reads and writes it through .value: in the template, in a computed's getter, and in handlers.
import { computed, defineEmits, ref } from "unframework";
export interface CounterProps {
initial?: number;
step?: number;
}
export default function Counter({ initial = 0, step = 1 }: CounterProps) {
const emit = defineEmits<{ change: [value: number] }>();
const count = ref(initial);
const doubled = computed(() => count.value * 2);
function increment() {
count.value += step;
emit("change", count.value);
}
return (
<div class="counter">
<output>{count.value}</output>
{doubled.value > 10 ? <span>Big</span> : null}
<button type="button" onClick={increment}>
+{step}
</button>
</div>
);
}
Each framework gets its own state, derived value and handler:
import { useMemo, useRef, useState } from "react";
export interface CounterProps {
initial?: number;
step?: number;
}
export interface CounterEvents {
onChange?: (value: number) => void;
}
export default function Counter({ initial = 0, step = 1, onChange }: CounterProps & CounterEvents) {
const [count, setCount] = useState(initial);
const countRef = useRef(count);
const doubled = useMemo(() => count * 2, [count]);
function increment() {
countRef.current += step;
setCount(countRef.current);
onChange?.(countRef.current);
}
return (
<div className="counter">
<output>{count}</output>
{doubled > 10 ? <span>Big</span> : null}
<button type="button" onClick={increment}>
+{step}
</button>
</div>
);
}
- React declares
useStateand its setter. React runs the component's body again on every render, so client code reads and writes a mirror ref,countRef, which is written before each setter call. A read right after a write sees the new value, in the same function and after anawait(see Semantics). Render reads the state itself. - Vue copies the setup as written, but declares state that may hold an object or an array with
shallowRef(Objects and arrays). Its template reads a ref without.value, because Vue unwraps it there. - Svelte declares
$state, and writes it by assignment. The setup reads a prop once, on purpose, so the seed goes throughuntrack. - Angular declares a
signal, written withsetandupdate. State seeded from an input is alinkedSignal, read once inngOnInit: Angular sets inputs after it constructs the class, so a field initialiser would see an optional input's default, and throw for a required one. The template reads each signal once, with@let. - Solid declares
createSignal. A read is a call,count(), and a write is a setter call, which Solid applies at once. Functions are copied as written: the watchers call back once for a synchronous run's writes, through the scheduler of Effects. - Qwik declares
useSignal, read and written through.value, and makes the handler a$()function, which Qwik loads when the event first fires. - Astro renders once, on the server. State is a constant of its initial value, and the handler is dropped: UF4001 notes, for information, that Astro's handlers never run.
defineEmits and emit are on Events.
Reading and writing
Read a ref's value as count.value. A ref used without .value where its value is meant is UF3026, with a fix that reads .value: React's count is the value, Solid's count() and Angular's this.count(), so the compiler reads count.value as one name. A ref is passed whole only where an API takes it: a watcher's source, watch(count, …), and an element's ref={field}.
Only client code writes state: a handler, a watcher's callback, watchEffect, a lifecycle hook, or a local function they call. A template, a getter and an initial value write nothing. A write is a statement of its own, because React, Solid and Angular turn it into a setter call:
count.value = 1,count.value += stepandcount.value++, as a statement;- the whole expression body of a handler, or of a watcher's, an effect's, a hook's or an
onCleanupcallback:onClick={() => count.value++}.
A write anywhere else is UF2011, and so is a write of a prop, a computed, a constant, a local function or a list's item. A local function whose expression body is a write, const flip = () => (on.value = !on.value), or an arrow passed to a call, setTimeout(() => count.value++) or .then((n) => (total.value = n)), would return the write's value, which differs from target to target: the fix gives it a block body. The fix is likely, not safe, where the call uses the callback's value, as map and then do. The bitwise and shift compound assignments (|=, <<=, …) are UF1002.
A ref that ref() declares with no type argument and no initial value holds only undefined to React, Solid and Angular, which infer a state's type from what the source gives them: write ref<string>() (UF2021). State that may hold a function is UF2021 too, because React's setter calls a function it is given.
Objects and arrays
State is replaced whole, never changed in place. Build the new value and write it: tasks.value = [...tasks.value, task], { ...profile.value, name }, tasks.value.filter(…), toSorted, toReversed and toSpliced.
const tasks = ref(initial);
const selected = ref<string>();
let added = 0;
function add() {
added += 1;
tasks.value = [...tasks.value, { id: `new-${added}`, label: `New task ${added}` }];
}
const [tasks, setTasks] = useState(initial);
const tasksRef = useRef(tasks);
const [selected, setSelected] = useState<string>();
const selectedRef = useRef(selected);
const added = useRef(0);
function add() {
added.current += 1;
tasksRef.current = [
...tasksRef.current,
{ id: `new-${added.current}`, label: `New task ${added.current}` },
];
setTasks(tasksRef.current);
}
No target's state is deep. Vue's output declares state that its type or its initial value does not show to be a primitive, such as an object or an array, with shallowRef, and Svelte's with $state.raw, so the value is the source's own object rather than a deep proxy: identity, structuredClone and an emitted payload are the source's. So a change in place, tasks.value.push(task), would render again on no target. A member assignment (a destructuring's and a loop's target too), delete, a member's ++, a method that changes its receiver (push, splice, sort, a Map's set, …) and Object.assign are UF2004 on a ref's value, a prop, a list's item, a constant, a parameter, or a member of a copy, which the copy shares with what it copies. Where the result is not used, the likely fix writes the new value whole.
Code can still change in place a value that no state shares: an object or an array it builds itself (a literal, new Map(), a copy written with a spread, or the new array that filter, map, slice and concat return, from state too) and what it put in one; a deep copy, structuredClone(…); an element through a template ref, the handler's event and a global such as document.title; and a local, a setup let or a reduce accumulator that only ever holds one of these. A getter may sort the array filter built, and fill a record it built:
const open = computed(() =>
tasks.value.filter((task) => !task.done).sort((a, b) => a.rank - b.rank),
);
const alphabetical = computed(() =>
tasks.value.slice().sort((a, b) => (a.title < b.title ? -1 : a.title > b.title ? 1 : 0)),
);
const newestFirst = computed(() => tasks.value.map((task) => task.title).reverse());
const kinds = computed(() => {
const byKind: Record<string, Task[]> = {};
for (const task of tasks.value) {
if (!byKind[task.kind]) byKind[task.kind] = [];
byKind[task.kind]!.push(task);
}
return Object.keys(byKind).map((kind) => `${kind}: ${byKind[kind]!.length}`);
});
A member of a copy is shared, so copy what you change too:
function addTag(tag: string) {
const next = { ...filters.value, tags: [...filters.value.tags] };
next.tags.push(tag);
filters.value = next;
}
Setup lets
A let in the setup, such as added above, holds what no template reads: a counter, a timer's id, a flag. It is not reactive, so only client code reads and writes it. A template, a getter, an initial value, a watcher's source or watchEffect that reads one is UF2010: hold such a value in a ref. What a watchEffect hands on to run later, a timer's callback or an onCleanup callback, may read one: onCleanup(() => clearInterval(timer)). A let with neither an annotation nor an initial value, or with only undefined or null, is UF2021, and so is an annotated let with no initial value whose type leaves out undefined: write let started: number | undefined;.
- React keeps it in a
useRef, read and written through.current, because its body runs on every render. - Angular makes it a private field, and Qwik a
useSignal: Qwik copies a plainletinto each$()function that captures it, so a write would never reach the others. - Vue, Svelte and Solid keep the
letas written. Astro drops it with the code that uses it.
Derived values
computed(getter) derives a value from props, state and other derived values. It is read-only, and read as total.value:
const quantity = ref(1);
const subtotal = computed(() => quantity.value * price);
const tax = computed(() => Math.round(subtotal.value * taxRate));
const total = computed(() => subtotal.value + tax.value);
const tier = computed(() => {
if (total.value >= 10000) return "bulk";
return "standard";
});
const [quantity, setQuantity] = useState(1);
const quantityRef = useRef(quantity);
const subtotal = useMemo(() => quantity * price, [quantity, price]);
const tax = useMemo(() => Math.round(subtotal * taxRate), [subtotal, taxRate]);
const total = useMemo(() => subtotal + tax, [subtotal, tax]);
const tier = useMemo(() => {
if (total >= 10000) return "bulk";
return "standard";
}, [total]);
- React writes
useMemo, with every prop, state, derived value and setup value the getter reads as a dependency, where the template, a getter, a watcher's source orwatchEffectreads the value. Client code calls a getter over the mirrors instead, so acomputedthat only client code reads has nouseMemo. - Svelte writes
$derived, and$derived.byfor a getter with a block body. - Angular, Solid and Qwik write
computed,createMemoanduseComputed$. - Astro writes the getter's value: an expression getter is a constant, and a block getter is a local function,
getTier, and one call.
React's useMemo and Solid's createMemo evaluate a getter when the component renders, not when code reads it. So a getter must give a value for every state the component can reach, and it reads only props and the bindings declared before it (UF2023): Vue's lazy computed would hide a read of one declared after it, which React's output would throw on. A function declaration is hoisted, so a getter may call one declared after it, where what that function reads is declared before the getter.
A getter is pure. It writes nothing, emits nothing, reads no template ref and no setup let, and calls only local functions that read nothing but constants that read nothing themselves (UF2014): Qwik moves such functions, with their constants, out of the component, where its getter can reach them. Like a template, a getter cannot depend on the clock, chance or the machine's locale (UF3019).
A computed read in client code right after a write of what it reads is up to date on every target. React reads it there through a getter over the mirrors (see Semantics).
Constants and functions
A setup const and a local function are written as in any TypeScript, and every target keeps them in source order. A template can call a local function that changes nothing:
import { computed, defineEmits, ref } from "unframework";
export type ShippingMethod = "standard" | "express";
export interface CheckoutTotalProps {
/** The order's subtotal, in cents. */
subtotal: number;
}
export default function CheckoutTotal({ subtotal }: CheckoutTotalProps) {
const emit = defineEmits<{ shippingChange: [method: ShippingMethod] }>();
const rates = { standard: 0, express: 1200 };
function formatCents(cents: number): string {
return `EUR ${(cents / 100).toFixed(2)}`;
}
const method = ref<ShippingMethod>("standard");
const shipping = computed(() => rates[method.value]);
const total = computed(() => formatCents(subtotal + shipping.value));
function choose(next: ShippingMethod) {
method.value = next;
emit("shippingChange", next);
}
const chooseExpress = () => {
choose("express");
};
function chooseStandard() {
choose("standard");
}
return (
<section class="checkout-total" aria-label="Order total">
<p>Subtotal: {formatCents(subtotal)}</p>
<p>Shipping: {formatCents(shipping.value)}</p>
<p role="status">Total: {total.value}</p>
<button type="button" aria-pressed={method.value === "standard"} onClick={chooseStandard}>
Standard shipping
</button>
<button type="button" aria-pressed={method.value === "express"} onClick={chooseExpress}>
Express shipping
</button>
</section>
);
}
const rates = { standard: 0, express: 1200 };
function formatCents(cents: number): string {
return `EUR ${(cents / 100).toFixed(2)}`;
}
export default function CheckoutTotal({
subtotal,
onShippingChange,
}: CheckoutTotalProps & CheckoutTotalEvents) {
const [method, setMethod] = useState<ShippingMethod>("standard");
const methodRef = useRef(method);
const shipping = useMemo(() => rates[method], [method]);
const total = useMemo(() => formatCents(subtotal + shipping), [subtotal, shipping]);
function choose(next: ShippingMethod) {
methodRef.current = next;
setMethod(methodRef.current);
onShippingChange?.(next);
}
const chooseExpress = () => {
choose("express");
};
function chooseStandard() {
choose("standard");
}
- React, Solid and Qwik move a constant or a function that reads nothing of the component's out of it, to the module's top level, as their lint rules ask. The rest stays in the component, as written, before the code that reads it: React Compiler rejects a read before a declaration.
- React declares a function that client code passes as a value, to
addEventListeneror a timer, once for the instance's life,const [onKey] = useState(() => (event: KeyboardEvent) => {…}), so the listener one handler adds is the one another removes. - Angular makes a constant a
readonlyfield and a function a method. What the template reads isprotected, and the restprivate. A function passed as a value is an arrow field, which keeps itsthis, declared before the setup's state, derived values and constants, so an initial value can call it. - Qwik makes a function that client code calls a
$()function. It awaits a call that is a statement, or whose value is used, so that a read after the call sees its writes, and a caller that awaits becomesasync. A call of a function that returns a promise is that promise, awaited only where the source awaits it. Where a call must not wait, in a listener that another listener of the event follows or in a task, Qwik writes the function's statements in place. A function that a template calls stays a plain function, and one that client code passes toaddEventListeneris held in auseConstant, so it keeps one identity. - Astro keeps the constants and the functions its render reaches, and drops the others.
A local function is a function declaration or an arrow function in a const. Code calls it, and client code may also pass it as a call's argument, as in setTimeout(tick, 100). Any other use of it as a value is UF2022. Local functions never call themselves, directly or through each other, and client code calls one that touches state directly, never inside an arrow function that runs at once, such as an array method's callback (UF2024): Qwik's output awaits each such call. A function that returns a promise, async or annotated Promise<…>, may be called and passed anywhere, as in await Promise.all(shelves.map(countShelf)), and an async arrow function may call any local function.
A template, a getter and an initial value call only a local function whose code, and the code of every function it calls, writes nothing, emits nothing and reads nothing that client code alone may read (UF2014). A local function may be a type predicate, isAnswer(value: string): value is "yes" | "no", or an assertion function: every target keeps the narrowing where code calls it.
Setup runs once
A setup const is evaluated once, when the setup runs. One that reads a prop, a state or a derived value keeps the value it had then:
export interface WelcomeBannerProps {
name: string;
}
export default function WelcomeBanner({ name }: WelcomeBannerProps) {
const greeting = `Welcome, ${name}`;
const tips = ["Set up your profile", "Invite your team"];
return (
<section class="welcome-banner" aria-label="Welcome">
<h2>{greeting}</h2>
<p>Signed in as {name}</p>
<ul>
{tips.map((tip) => (
<li key={tip}>{tip}</li>
))}
</ul>
</section>
);
}
greeting keeps the first name, while the template's {name} follows the prop. That is sometimes meant, a creation-time snapshot, and often a mistake, so the compiler warns (UF2007), with a likely fix that makes it a computed. Every target keeps the snapshot:
const tips = ["Set up your profile", "Invite your team"];
export default function WelcomeBanner({ name }: WelcomeBannerProps) {
const [greeting] = useState(`Welcome, ${name}`);
- React keeps the snapshot in
useState, whose initial value it reads on the first render only. - Svelte and Solid read the prop through
untrack, which says the setup reads it once on purpose. - Angular reads the constant once in
ngOnInit, after it sets the inputs, through acomputedthat does not track them. - Qwik's
useConstantevaluates the value once. - Astro renders each request as a new instance, so the constant is the prop's value for that render.
ref(initial) seeds state from a prop in the same way, and is not reported: the state starts with the prop's value at creation, and keeps its own value when the prop changes (Semantics).
The setup's statements
The setup declares what the component holds. Each statement before the return is one of:
- a
constor aletthat declares one name: destructuring in the setup is UF1002, so declare each name with aconstof its own; - a
functiondeclaration; - a call of
watch,watchEffect,onMountedoronUnmounted(Effects, Lifecycle).
A statement that only does something, such as init(); or an if, is UF1002: the setup runs before the DOM exists, and on the server too, so move it into onMounted or a function. A return before the last statement is UF2012: move the condition into the JSX.
The macros and reactive APIs, ref, computed, watch, watchEffect, onMounted, onUnmounted, defineEmits, useTemplateRef and useId, are called at the top level of the body, as a statement or a const's value (UF2005): never inside a condition, a loop or a function, because React's output turns them into hooks. A result that must be held, a ref's, a computed's, a template ref's, an id's or emit, is bound by a const (UF2006). nextTick is a function, and client code calls it anywhere.
Import the API by name from "unframework". A name it does not export, a namespace or a default import is UF2016, and so are Vue's APIs that the language writes another way, with their canonical form: reactive (state is a ref, replaced whole), toRefs (props are destructured in the signature) and watchPostEffect (watch(…, { flush: "post" })). A type from the package used in a copied annotation, such as Ref or OnCleanup, is UF2019: the import is erased, so leave the annotation out where TypeScript infers it.
Client code (handlers, callbacks, hooks and the functions they call) is script or class code on every target, which TypeScript checks, so it may use TypeScript's syntax. It does not use var, labels, for…in, classes, generators, this, or a function inside other code: write an arrow function (UF1002). A type assertion on the target of a write of state or of a setup let, count.value! = 2, is UF1002 too, because the outputs spell that target their own way; the likely fix writes the target bare. So is a type query of a prop or a setup binding, typeof count or ReturnType<typeof format>, which no output can name: write the type itself.
Client code runs in the browser only, so it reads every global that lib.dom declares: document, localStorage, location, fetch, URLSearchParams, AbortController, the observers, HTMLInputElement, KeyboardEvent, and the rest. It reads through window the members whose bare name reads like the component's own, window.innerWidth or window.name, which a missing declaration would read silently (UF3020, with a likely fix).
Code React Compiler cannot compile
React's output runs through React Compiler, which cannot compile some of the code the language accepts yet: a ?., a ??, a ?: or an optional call inside a try block, a throw there, finally, a destructuring default or a parameter's default that it cannot reorder, such as one that reads a local's member (max = maxSize.value), a for head without a declaration or a test, for await, a BigInt literal and a few others. Every target runs such code as written:
function apply(patch: Partial<Settings>) {
const { theme = settings.value.theme, size = settings.value.size } = patch;
settings.value = { theme, size };
emit("applied", theme, size);
}
function grow() {
const clamp = (value: number, max = maxSize.value) => Math.min(value, max);
apply({ size: clamp(settings.value.size + 4) });
}
async function save() {
saving.value = true;
failure.value = "";
try {
const reply = await new Promise<SaveReply>((resolve) => {
finish = resolve;
});
if (reply.error) throw new Error(reply.error);
const id = reply.id ?? 0;
label.value = id === 0 ? "Saved as a draft" : `Saved as #${id}`;
emit("saved", id);
} catch (error) {
failure.value = error instanceof Error ? error.message : "Saving failed";
} finally {
saving.value = false;
}
}
React finds these shapes in its own output, and opts the component out of React Compiler with "use no memo", first in its body, with a comment that names the shape:
export default function DisplaySettings({ onApplied, onSaved }: DisplaySettingsEvents) {
// React Compiler 1.0 cannot compile a default value it cannot reorder yet: the component opts out of it.
"use no memo";
React runs that component without React Compiler's memoisation, and it renders and behaves as on the other targets. An emit inside a try block is one of these shapes, because React writes it as an optional call, onSaved?.(id).
Names
The setup's bindings are declared beside the props, as members of Angular's class among others. So their names keep to a parameter's rules, as a local function's and a handler's parameters do, and none is constructor, a name that starts with ng and a capital letter, one that starts with use and a capital letter (React's lint reads it as a hook), or one that ends in $, which Qwik reads as a lazy function (UF2003). A parameter or a local in setup code cannot take the name of a prop, a setup binding, emit or a list's variable around it (UF3024): the targets cannot rename a local.
Narrowing
Solid and Angular read a ref's value through a call, selected() or this.selected(), and Angular every input, this.inviter(), which TypeScript never narrows. A narrowing that shows a value present survives the call: where code relies on a condition, or an assignment before it, showing a ref's value, a prop or a member of either present, those targets assert the read.
function invite() {
if (selected.value) emit("select", selected.value);
}
function remove() {
if (!selected.value) return;
const member = selected.value;
selected.value = null;
emit("removed", member.name);
}
function invite() {
if (selected()) props.onSelect?.(selected()!);
}
function remove() {
if (!selected()) return;
const member = selected()!;
setSelected(null);
props.onRemoved?.(member.name);
}
protected invite() {
if (this.selected()) this.select.emit(this.selected()!);
}
protected remove() {
if (!this.selected()) return;
const member = this.selected()!;
this.selected.set(null);
this.removed.emit(member.name);
}
React reads such a value through a mirror ref's property, which TypeScript narrows, and asserts a derived value's live getter, currentSelected()!. A handler under a template condition that narrows a value reads the value as rendered there.
A narrowing to one kind of a union does not survive. A ref's value, a prop or a member of either that is one of several kinds, string | number, string[] | string or "a" | "b", or of several object shapes, is used where a condition narrows it (typeof, Array.isArray, ===, a discriminant) only as a member every kind has, a test or an operand. Read it into a local before the condition, which the likely fix writes (UF3031):
const text = query.value; if (typeof text === "string") emit("search", text.trim());reads it into a local;- a conditional child still narrows its branch on every target:
{typeof value.value === "string" && <p>{value.value.trim()}</p>}.
Not yet
reactiveandtoRefsare not part of the language: state is aref, and props are destructured in the signature.- A spread of a setup value onto an element lands with fallthrough, in M3.
defineModel,defineSlots,defineExpose,defineOptions,provideandinjectland with composition in M3 (UF1002).- Imports of
.tsand.uf.tsmodules, and the module's own top-level declarations, land in M5. - An
asyncsetup lands in M8.
The decisions behind this page are ADR-0008, ADR-0045 and ADR-0046.
SVG
Inline SVG renders the same DOM, in SVG's namespace, on every target, with SVG's case-exact element and attribute names.
Events
Listeners take Vue's event names and keep the DOM's semantics, with capture, once and passive options. A component declares the events it emits with defineEmits, and each framework gets its own callbacks.