Props
A component takes its props as its only parameter, typed by an object type. You destructure them, with a static default for any optional prop, or read them from one object as props.name. The compiler copies the type into every output and declares the props the way each framework does.
Destructured props
The canonical form destructures each prop by its own name:
export interface ProductTileProps {
name: string;
price: number;
stock: number;
available?: boolean;
}
export default function ProductTile({ name, price, stock, available = true }: ProductTileProps) {
return (
<article class="product-tile" aria-label={name}>
<h2>{name}</h2>
<p>Price: {price} EUR</p>
<p data-stock={stock}>{stock} in stock</p>
<p>{available ? "Available to order" : "Currently unavailable"}</p>
<button type="button" disabled={!available}>
Add {name} to basket
</button>
</article>
);
}
Each framework gets the same props, declared its own way:
export interface ProductTileProps {
name: string;
price: number;
stock: number;
available?: boolean;
}
export default function ProductTile({ name, price, stock, available = true }: ProductTileProps) {
return (
<article className="product-tile" aria-label={name}>
<h2>{name}</h2>
<p>Price: {price} EUR</p>
<p data-stock={stock}>{stock} in stock</p>
<p>{available ? "Available to order" : "Currently unavailable"}</p>
<button type="button" disabled={!available}>
Add {name} to basket
</button>
</article>
);
}
- React and Qwik keep the pattern and its defaults in the component's parameter.
- Vue destructures
defineProps<P>(). An optional prop without a default gets= undefined, so Vue does not turn an absent boolean prop intofalse. - Svelte destructures
$props(). - Angular declares one signal input per prop:
input.required<T>()for a required prop,input<T>()for an optional one, andinput<T, T | undefined>(default, { transform })for one with a default. The transform applies the default to an explicitundefinedtoo. The template reads each input once, with@let. - Solid never destructures its props, because they are reactive getters. It merges the defaults in with
mergeProps, typedsatisfies Partial<P>, and reads every prop asprops.name. A default that holds an object or an array literal makes the defaults aconst defaults: Required<Pick<P, …>>of their own, as inNoticebelow:satisfieswould type the prop by the literal, without the optional members an object leaves out, and an array of"info"as"info"[]rather thanTone[]. - Astro names the type
Propsand destructuresAstro.props.
A destructured prop that the template never reads is left out of the pattern, so the outputs pass their frameworks' unused-variable rules. The type still declares it, and Angular still declares its input. Vue keeps an optional one, because vue/require-default-prop asks for its default, under a local whose _ tells the lint it is unread:
const {
title,
summary,
minutes = 5,
featured: _featured = undefined,
category: _category = undefined,
pinned: _pinned = false,
layout: _layout = "row",
} = defineProps<ArticleTeaserProps>();
When the template reads no prop at all, every output still declares the props:
export interface ArticleSkeletonProps {
title: string;
summary?: string;
minutes?: number;
}
export default function ArticleSkeleton(_props: ArticleSkeletonProps) {
return (
<article class="article-skeleton" aria-busy="true" aria-label="Loading article">
<p>Loading the article</p>
</article>
);
}
export interface ArticleSkeletonProps {
title: string;
summary?: string;
minutes?: number;
}
export default function ArticleSkeleton(_props: ArticleSkeletonProps) {
return (
<article className="article-skeleton" aria-busy="true" aria-label="Loading article">
<p>Loading the article</p>
</article>
);
}
React, Solid and Svelte name destructured props nothing reads _props. An object nothing reads keeps its name when the name is _ and more (_props, _unused); otherwise React and Svelte write _ before it (_props for props, __ for a bare _), and Solid names it _props. Qwik's component takes no parameter, Vue calls defineProps<P>() alone, and Astro exports its Props, so that its lint counts the type as used.
The props object
When no prop has a default, a component can take its props as one object:
export interface AuthorBylineProps {
author: string;
published: string;
minutes: number;
affiliation?: string;
}
export default function AuthorByline(props: AuthorBylineProps) {
return (
<p class="byline">
By {props.author}, {props.affiliation ?? "independent"}
<br />
<time datetime={props.published}>{props.published}</time> · {props.minutes} min read
</p>
);
}
Read every prop as props.name. Any other use of the object is UF2001: passing it on, destructuring it, reading props[key], assigning to it, or reading through parentheses, (props).name, which the fix unwraps. The parameter's name cannot start with $$, which Astro's compiled component uses for its own names. A default needs the destructured form.
The outputs keep the object where their framework has one. React, Solid and Qwik take props as their parameter. The others declare it:
const props = withDefaults(defineProps<AuthorBylineProps>(), { affiliation: undefined });
let props: AuthorBylineProps = $props();
const props = Astro.props;
Angular declares the same inputs as for the destructured form.
Types
The props type is an inline object type, or a local interface or type alias:
type Availability = "in-stock" | "backorder" | "discontinued";
interface Finish {
sku: string;
label: string;
}
export interface ProductSpecProps {
name: string;
availability: Availability;
size: { width: number; depth: number; unit: "cm" | "in" };
finishes: Finish[];
Every output copies the declarations the props reach, exactly as written, and exports them as the source does. Angular, for example, types its inputs with them:
readonly finishes = input.required<Finish[]>();
A member is a property with a plain name, declared once (UF2001). Its type can be built from:
string,number,boolean,nullandundefined;- string, number and boolean literal types;
- unions of these;
- arrays:
T[],readonly T[],Array<T>andReadonlyArray<T>; - object type literals, and local interfaces and aliases, nested to any depth.
Anything else is UF1002, with the milestone that brings it:
| Type | Why it waits | Lands in |
|---|---|---|
| A function type or a method | Callbacks are events, and render functions are slots | M2 and M3 |
An imported or global type (Date), a utility type, a generic, extends, an intersection | The compiler reads types syntactically until its type oracle | M5 |
any, unknown, object, {}, symbol, bigint, a tuple, an enum, a template literal type | The same | M5 |
| An index signature or a computed key | The same | M5 |
A union of a boolean with string, "" or the prop's own kebab-case name | Vue reads "" and the prop's own name as true for such a prop | M5 |
A type declaration that no component's props reach is UF1002 too, and so is import type from any module but unframework: shared types land in M5. Until then, give each component that uses a shared type its own file. The Vite plugin joins the components of one file into one module on React, Solid, Qwik and Angular, where two outputs that both declare a type clash. (On Vue, Svelte and Astro it loads one component per file until M3.)
Defaults
A default goes on an optional prop, in the pattern:
export default function Notice({
message,
title = "Notice",
tone = "info",
priority = 1,
expanded = false,
tags = ["general"],
author = { name: "System" },
}: NoticeProps) {
A default is a static value: a string, number, boolean or null literal, a negated number, a template literal without expressions, or an array or object literal of those. It cannot read anything, not a prop, a global or a function's result (UF2002): Vue hoists defaults out of the component, and Angular reads them before any input is set. Write the value itself, or compute it in the template (pageCount ?? 10).
export interface NoticeProps {
message: string;
title?: string;
tone?: "info" | "warning";
priority?: number;
expanded?: boolean;
tags?: string[];
author?: { name: string };
}
export default function Notice({
message,
title = "Notice",
tone = "info",
priority = 1,
expanded = false,
tags = ["general"],
author = { name: "System" },
}: NoticeProps) {
return (
<section className="notice" aria-label={title} data-tone={tone}>
<h2>{title}</h2>
<p>{message}</p>
<details open={expanded}>
<summary>Priority {priority}</summary>
<p>
Tagged {tags.join(", ")}, posted by {author.name}.
</p>
</details>
</section>
);
}
The compiler also reports, as UF2001:
- a default on a required prop, which would never apply;
- a default other than
nullon a prop whose type admitsnull: Qwik's optimiser applies a destructured default tonulltoo.
Absent, undefined and null
A prop that is left out and a prop that is set to undefined are the same, on every target: the prop takes its default, or stays undefined. null is a value, and does not take the default.
undefined and null render nothing as text, and leave out an attribute they are bound to:
export interface ContactCardProps {
name: string;
jobTitle?: string;
team?: string;
pronouns?: string;
phone?: string | null;
}
export default function ContactCard({ name, jobTitle, team, pronouns, phone }: ContactCardProps) {
return (
<article class="contact-card" aria-label={name} data-team={team}>
<h2 title={pronouns}>{name}</h2>
<p>{jobTitle}</p>
<p>Phone: {phone ?? "not listed"}</p>
</article>
);
}
Given only name, the <article> has no data-team, the <h2> has no title, the first <p> is empty and the last reads "Phone: not listed". Given phone: "", the last reads "Phone:" and nothing more: ?? falls back only for null and undefined.
Compare with null to tell it from an absent prop:
<p data-phone={phone === null ? "withheld" : phone}>
Phone: {phone === null ? "withheld" : (phone ?? "not given yet")}
</p>
<p>{verified === null ? "Verification pending" : verified ? "Verified" : "Not verified"}</p>
<p data-phone={phone === null ? "withheld" : phone}>
Phone: {phone === null ? "withheld" : (phone ?? "not given yet")}
</p>
<p>{verified === null ? "Verification pending" : verified ? "Verified" : "Not verified"}</p>
On Qwik, a consumer that spreads its props onto the component, or builds it with jsx(), loses a null: Qwik deletes null props there, and the component sees them absent. Writing the props as attributes keeps them.
Names
A prop's name is ASCII letters and digits, starting with a letter: Angular's expression lexer reads no other letter. These names are reserved (UF2003):
key,ref,children,class,style,slotandis, which the frameworks read themselves;props,rawProps,constructorandAstro, which the outputs declare beside the props, andFragment, which Astro's output imports to render<>;- JavaScript's reserved words, Angular's expression keywords (
as,let,typeof, …) and the globals expressions may read (Math,String, …); - event names such as
onClick(events land in M2), and names that start withngand a capital letter, such asngIf.
A local type cannot take a name the outputs declare or import themselves, such as CSSProperties, Component or Partial. A type named Props is accepted when every component whose props reach it takes it as its props type: Astro's output declares a Props of its own for any other component.
Not yet
- A rest element (
...rest) is fallthrough, which lands with composition in M3. - Imported, generic and utility types land with the type oracle in M5.
The decisions behind this page are ADR-0001, ADR-0007 and ADR-0034.
Introduction
Unframework is the compiler that speaks seven UI frameworks. Write a component once, and it compiles to native React, Vue, Svelte, Angular, Solid, Qwik and Astro code.
Templates
The JSX a component returns is its template. Its expressions, text, conditionals, lists and fragments compile to each framework's own template syntax.