Components

Child components

A component imports another from its .uf.tsx file and renders it as a tag, with props and listeners. Every framework imports the child's own output, so each output tree checks and runs as it is.

Most interfaces are built from smaller parts: a form renders its fields, a list its rows, a page its sections. A component imports another from its .uf.tsx file and renders it as a tag, passing it props and listening to its events. Each framework's output imports the child's output in its own way, so the output trees stand on their own: no checker, bundler or loader needs to know about .uf.tsx.

Importing a component

Import the child's file by a relative path that ends in .uf.tsx, then render it with its name as a PascalCase tag. Pass its props as attributes, and listen to its events with on and the event's name:

Field.uf.tsx
import { defineEmits } from "unframework";

export interface FieldProps {
  label: string;
  tone?: "info" | "warn";
}

export default function Field({ label, tone = "info" }: FieldProps) {
  const emit = defineEmits<{ clear: [label: string] }>();
  return (
    <div class={["field", tone]}>
      <span>{label}</span>
      <button type="button" onClick={() => emit("clear", label)}>
        Clear {label}
      </button>
    </div>
  );
}
Form.uf.tsx
import { ref } from "unframework";

import Field from "./Field.uf.tsx";

export default function Form({ city }: { city: string }) {
  const cleared = ref("nothing");
  function clear(label: string) {
    cleared.value = label;
  }
  return (
    <section aria-label="Address">
      <Field label="Street" onClear={clear} />
      <Field label={city} tone="warn" onClear={(label) => clear(label.toUpperCase())} />
      <output>Cleared: {cleared.value}</output>
    </section>
  );
}

Form passes label as a static value once and bound to a prop once, leaves tone to its default the first time, and listens to clear with a local function and with an arrow function. Each target writes both files, and the parent imports the child's output from the same folder:

Field.tsx
import type { CSSProperties } from "react";

export interface FieldProps {
  label: string;
  tone?: "info" | "warn";
}

export interface FieldEvents {
  onClear?: (label: string) => void;
}

export interface FieldAttributes {
  className?: string;
  style?: CSSProperties;
}

export default function Field({
  label,
  tone = "info",
  onClear,
  className,
  style,
}: FieldProps & FieldEvents & FieldAttributes) {
  return (
    <div className={cx("field", tone, className)} style={style}>
      <span>{label}</span>
      <button type="button" onClick={() => onClear?.(label)}>
        Clear {label}
      </button>
    </div>
  );
}

/** Joins class names, and the keys of an object's truthy entries, into one `className`. */
function cx(...parts: unknown[]): string {
  const names: string[] = [];
  for (const part of parts) {
    if (typeof part === "string") {
      if (part) names.push(part);
    } else if (part && typeof part === "object") {
      for (const [key, on] of Object.entries(part)) {
        if (on) names.push(key);
      }
    }
  }
  return names.join(" ");
}
Form.tsx
import Field from "./Field";
import { type CSSProperties, useRef, useState } from "react";

export interface FormAttributes {
  className?: string;
  style?: CSSProperties;
}

export default function Form({ city, className, style }: { city: string } & FormAttributes) {
  const [cleared, setCleared] = useState("nothing");
  const clearedRef = useRef(cleared);

  function clear(label: string) {
    clearedRef.current = label;
    setCleared(clearedRef.current);
  }

  return (
    <section aria-label="Address" className={className} style={style}>
      <Field label="Street" onClear={clear} />
      <Field label={city} tone="warn" onClear={(label) => clear(label.toUpperCase())} />
      <output>Cleared: {cleared}</output>
    </section>
  );
}
  • React, Solid and Qwik import ./Field, Vue ./Field.vue, Svelte ./Field.svelte and Astro ./Field.astro.
  • Angular imports ./field, lists the class in imports, and writes the child's selector, <uf-field>. A static prop is bound too, [label]="'Street'", so the host element keeps no attribute (the Angular host).
  • Every output but Vue's declares class and style beside the props (React className and style), which a parent's class and style reach (Fallthrough). Vue passes them on by itself.

The compiler reads the child's public API while it compiles the parent: its props, events, models and slots. So an import it cannot resolve, of a missing file or of a name the file doesn't export, is UF1202, and a PascalCase tag that names no component in scope is UF3047, with a fix to the closest name. The Vite plugin passes the compiler its resolver, and recompiles a parent when a child's file changes.

Props and events

A component element takes the child's props, and nothing else. An attribute the child doesn't declare is UF3035, and a literal outside a prop's union of literals, such as tone="danger" for "info" | "warn", is UF3039. Both say which name or literal you probably meant, and their fix writes it. The exceptions are key, ref, class and style: key keys a list item, ref holds what the child exposes (Expose and component refs), and class and style fall through to its root.

onClear listens to the clear event the child declares with defineEmits (Events). Its handler is a local function or an arrow function, as on an element, and it receives the event's payload, never a DOM event. Each framework spells the listener its own way:

TargetonClear={clear} becomesNote
ReactonClear={clear}
Vue@clear="clear"
Svelteonclear={clear}The event's name in lower case
Angular(clear)="clear($event)"An arrow function becomes a method, (clear)="onClear($event)"
SolidonClear={clear}
QwikonClear$={clear}The listener may run after the emit (Semantics)
AstronothingAstro renders on the server only, so nothing listens (component-event)

A child's event never hears a native event of the same name: an onX that the child doesn't declare is UF3036, because no target passes a native listener through to a child's root. Listen to the native event on an element inside the child instead. On Angular, an event named like a DOM event, such as change or select, would also hear that event as it bubbles out of the host, so its output takes the uf prefix: change becomes ufChange, and an Angular consumer listens with (ufChange). A listener option, as in onClearOnce, is UF3043: a component's event is no DOM event, so it has no phase and nothing to remove.

Names keep their case, acronyms included: imageURL and userID stay props of those names, and a listener of pickedURL is onPickedURL, which Svelte writes onpickedurl. Vue's templates hyphenate them, as Vue does for every name it writes there: image-u-r-l, @picked-u-r-l.

Several components in one file

A file may export several components. Import them by name, and rename one with as if you like:

Badges.uf.tsx
export function Badge({ text }: { text: string }) {
  return <span class="badge">{text}</span>;
}

export function Pill({ text }: { text: string }) {
  return <span class="pill">{text}</span>;
}
Status.uf.tsx
import { Badge, Pill as Tag } from "./Badges.uf.tsx";

export default function Status({ state }: { state: string }) {
  return (
    <p class="status">
      <Badge text="Build" />
      <Tag text={state} />
    </p>
  );
}

Each component becomes an output file of its own, named after the component: Badge.tsx and Pill.tsx on React, badge.ts and pill.ts on Angular. React, Solid, Qwik and Angular keep the named import, import { Pill as Tag } from "./Pill". A Vue, Svelte or Astro file exports its component as the default, so those import the default under the local name, import Tag from "./Pill.vue". Angular writes a component's own selector whatever its local name, <uf-pill>.

A component that a file doesn't export is a sibling file too, for its own file's components to render:

Card.uf.tsx
// `CardIcon` is not exported: it is a sibling file of `Card`'s in each output (plan §4.1).
function CardIcon({ symbol }: { symbol: string }) {
  return (
    <span class="icon" aria-hidden="true">
      {symbol}
    </span>
  );
}

export default function Card({ title }: { title: string }) {
  return (
    <div class="card">
      <CardIcon symbol="+" />
      <h3>{title}</h3>
      <CardIcon symbol="-" />
    </div>
  );
}

CardIcon becomes CardIcon.tsx beside Card.tsx, or card-icon.ts beside card.ts. The script outputs export it by name, so that Card can import it, but it stays out of the module's public exports.

In lists and conditionals

A list's .map may render a component as its item, keyed as an element is (Templates):

Tasks.uf.tsx
<div role="list" aria-label="Tasks">
  {tasks.value.map((task) => (
    <Row key={task.id} name={task.name} done={task.done} />
  ))}
</div>

The key comes off the component on every target: React and Qwik write key on it, Vue :key, Svelte keys its {#each}, Solid renders it in a <For>, Angular's @for tracks it, and Astro, which renders once, drops it. A component may be a conditional's branch too, count.value > 0 ? <Notice … /> : <Notice … />, and the branch that renders swaps when the condition changes.

Some elements only belong inside a given parent: a <tr> in a table part, a <td> or a <th> in a row, a <dt> or a <dd> in a <dl>. A component may have such a root, and the parent's compile checks it where the component sits, as it checks an element: a component whose root is a <tr>, rendered in a <div>, is UF3003.

An <li> root, as Item has here, renders in a <ul> or an <ol>:

Item.uf.tsx
// Its root is an <li>, which belongs in a list: the parent's compile checks where it sits.
export default function Item({ label }: { label: string }) {
  return <li class="item">{label}</li>;
}

Angular can't render a root that HTML or ARIA ties to its parent, because its host element would sit between the root and its parent: axe reports a list item outside a list, and the HTML parser moves a host out of a table. So a root of li, dt, dd, tr, td, th, thead, tbody, tfoot, caption, colgroup, col, option, optgroup, summary or legend is unsupported on Angular (contextual-root, an error). We recommend keeping the <li> in the parent's .map and rendering the component inside it, which works on every target.

Recursion

A component may render itself. Termination is yours: render the recursion under a condition that ends it.

Countdown.uf.tsx
// Renders itself until it reaches zero: the recursion ends under a condition (ADR-0053).
export default function Countdown({ from }: { from: number }) {
  return (
    <div class="level">
      <span>{from}</span>
      {from > 0 ? <Countdown from={from - 1} /> : <strong>Liftoff</strong>}
    </div>
  );
}
Countdown.tsx
import type { CSSProperties } from "react";

export interface CountdownAttributes {
  className?: string;
  style?: CSSProperties;
}

export default function Countdown({
  from,
  className,
  style,
}: { from: number } & CountdownAttributes) {
  return (
    <div className={cx("level", className)} style={style}>
      <span>{from}</span>
      {from > 0 ? <Countdown from={from - 1} /> : <strong>Liftoff</strong>}
    </div>
  );
}

/** Joins class names, and the keys of an object's truthy entries, into one `className`. */
function cx(...parts: unknown[]): string {
  const names: string[] = [];
  for (const part of parts) {
    if (typeof part === "string") {
      if (part) names.push(part);
    } else if (part && typeof part === "object") {
      for (const [key, on] of Object.entries(part)) {
        if (on) names.push(key);
      }
    }
  }
  return names.join(" ");
}
  • React and Solid call their own function, and Qwik its own component$, which it declares as a named constant and exports.
  • Vue names the component with defineOptions({ name: "Countdown" }), and its template writes its own tag.
  • Svelte imports its own file, import Countdown from "./Countdown.svelte".
  • Angular writes its own selector, which needs nothing in imports.
  • Astro renders <Astro.self>.

A child named like one of Vue's built-in components, BaseTransition, Component, KeepAlive, Suspense, Teleport, Transition or TransitionGroup, would render the built-in in a Vue template. So Vue imports it under a name of its own, import KeepAliveComponent from "./KeepAlive.vue", and such a component that renders itself names itself the same way, defineOptions({ name: "KeepAliveComponent" }). The other targets keep the name.

Not yet

  • A component whose root is an SVG element, such as a <g>, lands in M8: its namespace comes from its parent, and Vue's templates have no way to declare it (UF1002).
  • A component whose root is an <li>, a <tr> or another element tied to its parent has no Angular output until Angular's attribute selectors land.

The decisions behind this page are ADR-0053, ADR-0054 and ADR-0056.

Copyright © 2026