Components

Props

A component takes its props as one typed parameter, destructured with static defaults or read as props.x, and every framework declares them its own way.

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:

ProductTile.uf.tsx
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:

ProductTile.tsx
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 into false.
  • Svelte destructures $props().
  • Angular declares one signal input per prop: input.required<T>() for a required prop, input<T>() for an optional one, and input<T, T | undefined>(default, { transform }) for one with a default. The transform applies the default to an explicit undefined too. 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, typed satisfies Partial<P>, and reads every prop as props.name. A default that holds an object or an array literal makes the defaults a const defaults: Required<Pick<P, …>> of their own, as in Notice below: satisfies would type the prop by the literal, without the optional members an object leaves out, and an array of "info" as "info"[] rather than Tone[].
  • Astro names the type Props and destructures Astro.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:

ArticleTeaser.vue
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:

ArticleSkeleton.uf.tsx
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>
  );
}
ArticleSkeleton.tsx
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:

AuthorByline.uf.tsx
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:

AuthorByline.vue
const props = withDefaults(defineProps<AuthorBylineProps>(), { affiliation: undefined });
AuthorByline.svelte
let props: AuthorBylineProps = $props();
AuthorByline.astro
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:

ProductSpec.uf.tsx
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:

product-spec.ts
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, null and undefined;
  • string, number and boolean literal types;
  • unions of these;
  • arrays: T[], readonly T[], Array<T> and ReadonlyArray<T>;
  • object type literals, and local interfaces and aliases, nested to any depth.

Anything else is UF1002, with the milestone that brings it:

TypeWhy it waitsLands in
A function type or a methodCallbacks are events, and render functions are slotsM2 and M3
An imported or global type (Date), a utility type, a generic, extends, an intersectionThe compiler reads types syntactically until its type oracleM5
any, unknown, object, {}, symbol, bigint, a tuple, an enum, a template literal typeThe sameM5
An index signature or a computed keyThe sameM5
A union of a boolean with string, "" or the prop's own kebab-case nameVue reads "" and the prop's own name as true for such a propM5

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:

Notice.uf.tsx
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).

Notice.tsx
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 null on a prop whose type admits null: Qwik's optimiser applies a destructured default to null too.

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:

ContactCard.uf.tsx
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:

ContactLine.uf.tsx
<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>
ContactLine.tsx
<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, slot and is, which the frameworks read themselves;
  • props, rawProps, constructor and Astro, which the outputs declare beside the props, and Fragment, 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 with ng and a capital letter, such as ngIf.

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.

Copyright © 2026