Components

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.

A component listens to its elements' events, and tells its parent what happened through events of its own. A listener is an on attribute with Vue's event name, and it hears the DOM's event on every target. A component's own events are declared once, with defineEmits, and each framework gets them in its own convention.

Listeners

A listener is on and the event's name, onClick, onInput or onKeydown, and its handler is a local function's name or an arrow function:

NoteEditor.uf.tsx
import { defineEmits, ref, useTemplateRef } from "unframework";

export default function NoteEditor() {
  const emit = defineEmits<{ saved: [count: number]; tagged: [tag: string, via: string] }>();

  const saves = ref(0);
  const lastKey = ref("none");
  const tags = ref<string[]>([]);
  const tag = ref("");
  const tagField = useTemplateRef<HTMLInputElement>();

  function save() {
    saves.value += 1;
    emit("saved", saves.value);
  }

  function recordKey(event: KeyboardEvent) {
    lastKey.value = event.key;
  }

  function addTag(event: MouseEvent | KeyboardEvent) {
    if (tag.value === "") return;
    tags.value = [...tags.value, tag.value];
    emit("tagged", tag.value, event.type);
    tag.value = "";
    if (tagField.value) tagField.value.value = "";
  }

  function onTagKeydown(event: KeyboardEvent) {
    if (event.key === "Enter") addTag(event);
  }

  return (
    <section class="note-editor" aria-label="Note">
      <button type="button" onClick={save}>
        Save
      </button>
      <label>
        Title
        <input name="title" onKeydown={recordKey} />
      </label>
      <label>
        Body
        <textarea name="body" onKeydown={recordKey} />
      </label>
      <p role="status">
        Saved {saves.value} times, last key {lastKey.value}
      </p>
      <button type="button" onClick={save}>
        Save and close
      </button>
      <label>
        Tag
        <input
          ref={tagField}
          name="tag"
          onInput={(event) => (tag.value = (event.currentTarget as HTMLInputElement).value)}
          onKeydown={onTagKeydown}
        />
      </label>
      <button type="button" onClick={addTag}>
        Add tag
      </button>
      <p>Tags: {tags.value.join(", ")}</p>
    </section>
  );
}

Each framework gets its own listeners:

NoteEditor.tsx
import { type KeyboardEvent, type UIEvent, useRef, useState } from "react";

export interface NoteEditorEvents {
  onSaved?: (count: number) => void;
  onTagged?: (tag: string, via: string) => void;
}

export default function NoteEditor({ onSaved, onTagged }: NoteEditorEvents) {
  const [saves, setSaves] = useState(0);
  const savesRef = useRef(saves);
  const [lastKey, setLastKey] = useState("none");
  const lastKeyRef = useRef(lastKey);
  const [tags, setTags] = useState<string[]>([]);
  const tagsRef = useRef(tags);
  const [tag, setTag] = useState("");
  const tagRef = useRef(tag);
  const tagField = useRef<HTMLInputElement>(null);

  function save() {
    savesRef.current += 1;
    setSaves(savesRef.current);
    onSaved?.(savesRef.current);
  }

  function recordKey(event: KeyboardEvent) {
    lastKeyRef.current = event.key;
    setLastKey(lastKeyRef.current);
  }

  function addTag(event: UIEvent) {
    if (tagRef.current === "") return;
    tagsRef.current = [...tagsRef.current, tagRef.current];
    setTags(tagsRef.current);
    onTagged?.(tagRef.current, event.type);
    tagRef.current = "";
    setTag(tagRef.current);
    if (tagField.current) tagField.current.value = "";
  }

  function onTagKeydown(event: KeyboardEvent) {
    if (event.key === "Enter") addTag(event);
  }

  return (
    <section className="note-editor" aria-label="Note">
      <button type="button" onClick={save}>
        Save
      </button>
      <label>
        Title
        <input name="title" onKeyDown={recordKey} />
      </label>
      <label>
        Body
        <textarea name="body" onKeyDown={recordKey} />
      </label>
      <p role="status">
        Saved {saves} times, last key {lastKey}
      </p>
      <button type="button" onClick={save}>
        Save and close
      </button>
      <label>
        Tag
        <input
          ref={tagField}
          name="tag"
          onInput={(event) => {
            tagRef.current = (event.currentTarget as HTMLInputElement).value;
            setTag(tagRef.current);
          }}
          onKeyDown={onTagKeydown}
        />
      </label>
      <button type="button" onClick={addTag}>
        Add tag
      </button>
      <p>Tags: {tags.join(", ")}</p>
    </section>
  );
}
  • React writes React's prop names, onKeyDown for onKeydown. A handler's parameter typed with a DOM event type, KeyboardEvent, gets React's synthetic type of the same name, imported from react: a DOM type on a React listener fails its type-check. A union of event types, MouseEvent | KeyboardEvent, gets the synthetic type of their nearest common interface, UIEvent.
  • Vue writes @click and @keydown, with the function's name.
  • Svelte writes Svelte 5's event attributes, onclick and onkeydown.
  • Angular binds (click)="save()", and passes the event as $event to a function whose first parameter takes it. Local functions are the class's methods.
  • Solid writes its own props, onClick and onKeyDown.
  • Qwik writes onClick$ and onKeyDown$, and each handler is a $() function, which Qwik loads when the event first fires. A call of one that is a statement is awaited, await addTag(event), so that a read after it sees its writes.
  • Astro drops every listener: its components render on the server only, so their handlers never run (UF4001, for information).

Event names

Write the event's name as Vue's JSX does: on and the DOM event's name, with its first letter in upper case. React's spellings, onKeyDown and onDoubleClick, are UF3004, with a safe fix to onKeydown and onDblclick. The compiler writes each framework's spelling for you.

An event the element does not have is UF3006, and so is an event only the window has, such as hashchange or popstate. An element listens to an event once per option (UF3007).

Handlers

A handler is the name of a local function, which receives the event as its first parameter, or an arrow function written in place, whose one parameter is the event. The arrow function can have an expression body or a block body, and it can be async:

ShirtOrder.uf.tsx
<button type="button" onClick={() => quantity.value++}>
  Add one
</button>
<label>
  Note
  <input
    name="note"
    ref={noteField}
    onInput={(event) => (note.value = (event.currentTarget as HTMLInputElement).value)}
  />
</label>
ShirtOrder.tsx
<button
  type="button"
  onClick={() => {
    quantityRef.current++;
    setQuantity(quantityRef.current);
  }}
>
  Add one
</button>
<label>
  Note
  <input
    name="note"
    ref={noteField}
    onInput={(event) => {
      noteRef.current = (event.currentTarget as HTMLInputElement).value;
      setNote(noteRef.current);
    }}
  />
</label>
  • Vue keeps a handler of one expression in the template, as Vue's own docs write it (@click="quantity++"), and the arrow function where it reads its event. A handler the template cannot hold, such as one with several statements, becomes a function in the script.
  • Angular keeps an inline handler that is one call, with arguments its template can read, as a template statement. It moves every other to a protected method named after its element, onAddOne or onNoteInput, and types the event parameter with the event's DOM type. Its listeners never return a value, because Angular prevents the default when a listener returns false: a method it moves a handler to returns nothing, and a call of a function that returns a value is void toggle().
  • React writes each state write as a mirror's write and a setter call, in a block.
  • Qwik reads event.currentTarget as the handler's second argument, element. Qwik dispatches every event from one listener on the document, where currentTarget is the document.

A call, onClick={save()}, runs while the component renders, so it is UF3029, and so are a string, a conditional, a member, a function expression, emit itself, and a function whose first parameter does not take the event. A handler's return value is discarded on every target.

A handler reads its event only as event.member, for a member that both the DOM's event and React's synthetic event have: key, code, the modifier keys, button, clientX, currentTarget, target, preventDefault() and others. It may also pass the event whole to a local function's event parameter. Anything else is UF3032: React's events lack isComposing, composedPath, stopImmediatePropagation and offsetX, among others. A local function that handles several events takes a union of their types, as addTag(event: MouseEvent | KeyboardEvent) above does, and reads only the members that every event of the union has.

Handlers in lists

A handler inside a list that reads the list's item or its index is one call of a local function or of emit, with arguments that a template can read:

ContactList.uf.tsx
<button
  type="button"
  aria-pressed={chosen.value === contact.id}
  onClick={() => pick(contact, index)}
>
  {contact.name}
</button>
ContactList.tsx
<button
  type="button"
  aria-pressed={chosen === contact.id}
  onClick={() => pick(contact, index)}
>
  {contact.name}
</button>

Angular's template statement passes the item and the index to the class's method, which is why the handler is one call. The same holds for a handler that reads a value that a condition around it narrows, such as {user && <button onClick={() => greet(user.name)}>…</button>}. Anything else there is UF3029.

The DOM's events

A listener hears the DOM's event of its name, on every target. change on a text field fires when the value is committed, on blur or Enter, not on each key, and focus and blur do not bubble:

SignupForm.uf.tsx
<label>
  Name
  <input
    name="name"
    onChange={(event) => (fullName.value = (event.currentTarget as HTMLInputElement).value)}
    onFocus={() => (focused.value = "name")}
    onBlur={() => (focused.value = "none")}
  />
</label>
SignupForm.tsx
const [nameListeners] = useState(
  () => (element: Element | null) =>
    listen(element, "change", (event) => {
      fullNameRef.current = (event.currentTarget as HTMLInputElement).value;
      setFullName(fullNameRef.current);
    }),
);
SignupForm.tsx
<label>
  Name
  <input
    name="name"
    ref={nameListeners}
    onFocus={() => {
      focusedRef.current = "name";
      setFocused(focusedRef.current);
    }}
    onBlur={() => {
      focusedRef.current = "none";
      setFocused(focusedRef.current);
    }}
  />
</label>

React's synthetic onChange fires on every key in a text field, and its onFocus and onBlur hear a descendant's focus. So React listens natively where its event differs from the DOM's, from the element's ref callback, through a small helper at the end of the file (event-semantics, emulated). The callback is declared once for the component's life (useState), so React never removes and adds the listeners again as it renders, and the listeners of one event on one element run from one listen call, in attribute order:

SignupForm.tsx
/**
 * Listens to a DOM event natively, where React's synthetic event would not keep the DOM's
 * semantics, and gives back what stops it: the cleanup a ref callback returns.
 */
function listen<K extends keyof HTMLElementEventMap>(
  element: EventTarget | null,
  type: K,
  listener: (event: HTMLElementEventMap[K]) => void,
  options?: AddEventListenerOptions,
): () => void {
  element?.addEventListener(type, listener as EventListener, options);
  return () => element?.removeEventListener(type, listener as EventListener, options);
}
  • React listens natively to change on a text field, a checkbox or a radio (not on a <select> or a file input: React's onChange on a checkbox comes from the click, before input, even for a prevented click), to select, beforeinput and the events React has no prop for, to a wheel, touchstart or touchmove listener that is not passive, and to focus and blur where a native focusin or focusout listener is on the same path (React's onFocus is its root's focusin, which would run after it). A synthetic focus or blur listener on an element that can hold a focused descendant returns unless event.target is the element.
  • Solid delegates some bubbling events, such as click, input and keydown, to the document, and listens natively to the others, focus and change among them.
  • Vue, Svelte, Angular and Qwik listen to the DOM's own events.

Options

An option is a suffix of the listener's name: onClickCapture listens in the capture phase, onClickOnce runs once for each element, and onWheelPassive tells the browser that the handler will not prevent the default. A listener takes one option at most, and Passive only on wheel, touchstart and touchmove (UF3006):

EventLog.uf.tsx
<div
  class="panel"
  role="presentation"
  onClickCapture={() => record("panel capture")}
  onClick={() => record("panel bubble")}
>
  <button type="button" onClick={() => record("button")}>
    Inside
  </button>
  <button
    type="button"
    onClick={(event) => {
      event.stopPropagation();
      record("stopped");
    }}
  >
    Stop here
  </button>
</div>
<button type="button" onClickOnce={() => record("once")}>
  Only once
</button>
<div class="reward" role="presentation" onClick={() => record("outer")}>
  <button
    type="button"
    onClickOnce={(event) => {
      event.stopPropagation();
      record("claimed");
    }}
  >
    Claim the reward
  </button>
</div>
<div class="volume" role="group" aria-label="Volume" onWheelPassive={changeVolume}>
  <output>{volume.value}</output>
</div>
EventLog.tsx
<div
  className="panel"
  role="presentation"
  onClickCapture={() => record("panel capture")}
  onClick={() => record("panel bubble")}
>
  <button type="button" onClick={() => record("button")}>
    Inside
  </button>
  <button
    type="button"
    onClick={(event) => {
      event.stopPropagation();
      record("stopped");
    }}
  >
    Stop here
  </button>
</div>
<button
  type="button"
  onClick={(event) => {
    if (!clickOnce(event)) return;
    record("once");
  }}
>
  Only once
</button>
<div className="reward" role="presentation" onClick={() => record("outer")}>
  <button
    type="button"
    onClick={(event) => {
      if (!clickOnce_1(event)) return;
      event.stopPropagation();
      record("claimed");
    }}
  >
    Claim the reward
  </button>
</div>
<div className="volume" role="group" aria-label="Volume" onWheel={changeVolume}>
  <output>{volume}</output>
</div>
  • Vue writes its modifiers, .capture, .once and .passive. An inline handler that starts with event.stopPropagation() or event.preventDefault() writes .stop or .prevent before the rest.
  • Svelte writes onclickcapture. It has no attribute for once and passive, so those listen through on from svelte/events, in an attachment: a passive listener with the DOM's own option, and a once listener through a guard (below).
  • Solid writes on:click={{ handleEvent, once: true }}. Once a component listens natively to an event that Solid delegates, with once or passive or a handler typed as the DOM types the event, its other listeners of that event are native too, so they run in the DOM's order.
  • Astro drops every listener.

Svelte runs the handlers it delegated below an element inside that element's own listener, so the DOM's { once: true } would be used up by a click that a descendant stopped. A once listener's handler goes through an inline guard instead, set when the handler first runs (event-once, emulated):

EventLog.svelte
function once<E extends Event>(handler: (event: E) => unknown): (event: E) => void {
  let ran = false;
  return (event) => {
    if (ran) return;
    ran = true;
    handler(event);
  };
}

React writes onClickCapture, and onWheel for onWheelPassive, because React's root wheel listener is passive already. React has no once, so a guard remembers each element the listener ran for, as { once: true } removes a listener from its own element only (event-once, emulated):

EventLog.tsx
/** The guard of a listener that runs once per element, as `{ once: true }` makes it. */
function useOnce(): (event: { currentTarget: EventTarget | null }) => boolean {
  const fired = useRef(new WeakSet<EventTarget>());
  return (event) => {
    const target = event.currentTarget;
    if (!target || fired.current.has(target)) return false;
    fired.current.add(target);
    return true;
  };
}

Angular's template bindings take no listener options, so the file declares an attribute directive for each event and option, which listens with Renderer2.listen and emits the event again (event-capture, event-once and event-passive, emulated). A directive works inside conditionals and lists:

event-log.ts
@Directive({ selector: "[ufClickCapture]" })
export class ClickCapture {
  readonly ufClickCapture = output<PointerEvent>();

  constructor() {
    const element: HTMLElement = inject(ElementRef).nativeElement;
    const stop = inject(Renderer2).listen(
      element,
      "click",
      (event: PointerEvent) => this.ufClickCapture.emit(event),
      { capture: true },
    );
    inject(DestroyRef).onDestroy(stop);
  }
}

Qwik writes passive:wheel, and capture:click where no element of the component listens to the event in both phases. The panel above listens to click in both phases, which Qwik cannot tell apart on one element, so every capture listener of click in the component runs from the window, before Qwik's own listener on the document, and returns unless the click is inside its element. Qwik has no once either, so a set of the elements each such listener ran for sits at the end of the file (event-once, emulated):

EventLog.tsx
// The elements each `once` listener ran for: Qwik's listeners have no `once` option.
const onceClick = new WeakSet<Element>();
const onceClick_1 = new WeakSet<Element>();

Where the component also listens to an event without passive, Qwik's loader would run a passive listener of that event in a pass of its own, out of the DOM's order, so Qwik writes it as a plain listener.

A passive listener tells the browser that it will not prevent the default, so the browser ignores its preventDefault(). Angular and Qwik may run it in a listener that is not passive, where the call would prevent. So what a passive listener runs, its handler and the local functions it passes its event to, calls no preventDefault() (UF3034), with a safe fix that removes the call.

Several listeners of one event

An element may listen to one event in one phase with a plain listener and an option, such as onClick and onClickOnce. The listeners run in their attributes' order on every target:

ListenerOrder.uf.tsx
<button
  type="button"
  onClick={() => record("save")}
  onClickOnce={() => record("first save")}
>
  Save
</button>
ListenerOrder.tsx
<button
  type="button"
  onClick={(event) => {
    record("save");
    if (clickOnce(event)) {
      record("first save");
    }
  }}
>
  Save
</button>
  • React and Qwik run the listeners in one handler, in order, each once one under its guard. A listener that is async is called and left to finish, so every later listener starts while the event is dispatched.
  • Qwik runs a listener once its code has loaded. A listener that another listener of the event follows, on its element or on the path, writes its calls of local functions in place, as the buttons inside the panel above do, so that it is done when the next one starts. A listener's first run, before its code has loaded, is outside the contract (Semantics).
  • Vue and Svelte write each listener as an attribute, in order.
  • Angular runs a directive's listener before the template's, so the listeners are one template listener that chains their statements in order, a once one under a guard, once(clickOnce, $event) && …. The guard's set is one per event, and the method marks the element the listener ran for. The chain's last statement is void: Angular prevents the default when a listener returns false.
  • Solid adds them all from the element's ref callback, in order: its compiler adds a ref's listeners and an element's own in an order of its own.

Preventing the default

event.preventDefault() and event.stopPropagation() act only while the browser dispatches the event. So call them in the handler itself, before its first await: a call after an await, or in a function the handler hands on, such as a timer's callback or a promise's continuation, comes too late on every target (UF3033). Before that, a call may stand anywhere: under a test, in a switch case, beside other statements or after a guard clause:

TagForm.uf.tsx
function blockComma(event: KeyboardEvent) {
  if (event.key === ",") event.preventDefault();
}

function updateDraft(event: InputEvent) {
  draft.value = (event.currentTarget as HTMLInputElement).value;
}

function addTag(event: SubmitEvent) {
  event.preventDefault();
  if (draft.value !== "" && !tags.value.includes(draft.value)) {
    tags.value = [...tags.value, draft.value];
    emit("tagsChange", tags.value);
  }
  draft.value = "";
  const input = field.value;
  if (input) input.value = "";
}

Every target but Qwik keeps the calls in the handler. Vue writes those an inline handler starts with as its .stop and .prevent modifiers, which run first.

Qwik loads a handler and runs it after the event, so it runs the calls apart, while the event is dispatched. A call at the top of a listener, or at the top of a local function that the listener calls at its top, becomes preventdefault:submit or stoppropagation:click on the element, which Qwik applies as it dispatches. A call there under a test that reads only the event runs in a sync$ function, which captures nothing. A listener that did nothing but these calls keeps a sync$ function that makes them as its handler, because Qwik listens only to the events that some element has a handler of:

TagForm.tsx
import { $, type QRL, component$, sync$, useSignal } from "@qwik.dev/core";

export interface TagFormEvents {
  onTagsChange$?: QRL<(tags: string[]) => void>;
}

export default component$<TagFormEvents>(({ onTagsChange$ }) => {
  const tags = useSignal<string[]>([]);
  const draft = useSignal("");
  const helpOpen = useSignal(false);
  const field = useSignal<HTMLInputElement>();

  const updateDraft = $((event: InputEvent, element: Element) => {
    draft.value = (element as HTMLInputElement).value;
  });

  const addTag = $(() => {
    if (draft.value !== "" && !tags.value.includes(draft.value)) {
      tags.value = [...tags.value, draft.value];
      onTagsChange$?.(tags.value);
    }
    draft.value = "";
    const input = field.value;
    if (input) input.value = "";
  });

  const toggleHelp = $(() => {
    helpOpen.value = !helpOpen.value;
  });

  return (
    <form class="tag-form" aria-label="Tags" preventdefault:submit onSubmit$={addTag}>
      <label>
        New tag
        <input
          name="tag"
          ref={field}
          onKeyDown$={sync$((event: KeyboardEvent) => {
            if (event.key === ",") event.preventDefault();
          })}
          onInput$={updateDraft}
        />
      </label>
      <button type="submit">Add tag</button>
      <a href="/help/tags" preventdefault:click onClick$={toggleHelp}>
        How tags work
      </a>
      {helpOpen.value ? <p>A tag is one word: commas are not allowed.</p> : null}
      <ul aria-label="Added tags">
        {tags.value.map((tag) => (
          <li key={tag}>{tag}</li>
        ))}
      </ul>
    </form>
  );
});

Where the listener does more, its sync$ function runs before its $() handler, and a guard clause that tests only the event decides both:

CommentComposer.uf.tsx
function send(event: KeyboardEvent) {
  if (event.key !== "Enter") return;
  event.preventDefault();
  emit("sent", (event.target as HTMLTextAreaElement).value);
}
CommentComposer.tsx
<label>
  Comment
  <textarea
    name="comment"
    onKeyDown$={[
      sync$((event: KeyboardEvent) => {
        if (event.key === "Enter") {
          event.preventDefault();
        }
      }),
      send,
    ]}
  />
</label>

Qwik cannot run any other call at dispatch: one after another statement, in a block beside other statements, in an else or a switch case, after a test of state or of event.defaultPrevented, or in a local function the listener calls anywhere but at its top. Nor can it run a once listener's call under a condition or on an element that listens to the event in both phases, a capture listener's call where an element of the component listens to the event in both phases, as the panel above does, or a call in a local function that client code hands to addEventListener. It reports such a call, conditional-event-control (UF4001, an error on Qwik), and the component has no Qwik output. The keyboard handler of a list box is one, though the six other targets run it as written:

CityPicker.uf.tsx
function onCityKeydown(event: KeyboardEvent) {
  switch (event.key) {
    case "ArrowDown":
      event.preventDefault();
      open.value = true;
      active.value = Math.min(active.value + 1, matches.value.length - 1);
      break;
    case "ArrowUp":
      event.preventDefault();
      active.value = Math.max(active.value - 1, 0);
      break;
    case "Enter": {
      event.preventDefault();
      const city = matches.value[active.value];
      if (open.value && city !== undefined) choose(city);
      break;
    }
    case "Escape":
      event.preventDefault();
      open.value = false;
      active.value = -1;
      break;
  }
}

To keep a component on Qwik, make each call the first statement of its listener, or the only statement under an if that tests only the event.

Accessible handlers

A listener on an element that a keyboard user cannot reach, or that a screen reader does not announce as interactive, leaves them without the action. Svelte's compiler warns about it, and the compiler reports the same cases for every target, as Svelte judges them (UF3030): a click handler with no key handler on a <div>, a handler on an element with no role, a handler on a non-interactive element such as <li>, an interactive role that cannot take focus, and mouseover or mouseout with no focus or blur handler. Put the handler on a <button> or a form control. A container that only listens to its buttons' clicks, as the panel above does, takes role="presentation".

Component events

defineEmits declares the events a component emits, as one type argument: an object type whose members are the events, each typed by a named tuple of its payload. emit sends one:

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

export interface FileInfo {
  path: string;
  size: number;
}

export interface FileRowProps {
  path: string;
  size: number;
}

export default function FileRow({ path, size }: FileRowProps) {
  const emit = defineEmits<{
    refresh: [];
    open: [path: string];
    move: [from: string, to: string];
    pick: [file: FileInfo];
    share: [path: string, note?: string];
  }>();

  function archive() {
    emit("move", path, `archive/${path}`);
    emit("refresh");
  }

  return (
    <div class="file-row" role="group" aria-label={path}>
      <span>
        {path} ({size} bytes)
      </span>
      <button type="button" onClick={() => emit("refresh")}>
        Refresh
      </button>
      <button type="button" onClick={() => emit("open", path)}>
        Open
      </button>
      <button type="button" onClick={archive}>
        Archive
      </button>
      <button type="button" onClick={() => emit("pick", { path, size })}>
        Select
      </button>
      <button type="button" onClick={() => emit("share", path)}>
        Share
      </button>
      <button type="button" onClick={() => emit("share", path, "Please review")}>
        Share with a note
      </button>
    </div>
  );
}

Each framework gets the events in its own convention:

FileRow.tsx
export interface FileInfo {
  path: string;
  size: number;
}

export interface FileRowProps {
  path: string;
  size: number;
}

export interface FileRowEvents {
  onRefresh?: () => void;
  onOpen?: (path: string) => void;
  onMove?: (from: string, to: string) => void;
  onPick?: (file: FileInfo) => void;
  onShare?: (path: string, note?: string) => void;
}

export default function FileRow({
  path,
  size,
  onRefresh,
  onOpen,
  onMove,
  onPick,
  onShare,
}: FileRowProps & FileRowEvents) {
  function archive() {
    onMove?.(path, `archive/${path}`);
    onRefresh?.();
  }

  return (
    <div className="file-row" role="group" aria-label={path}>
      <span>
        {path} ({size} bytes)
      </span>
      <button type="button" onClick={() => onRefresh?.()}>
        Refresh
      </button>
      <button type="button" onClick={() => onOpen?.(path)}>
        Open
      </button>
      <button type="button" onClick={archive}>
        Archive
      </button>
      <button type="button" onClick={() => onPick?.({ path, size })}>
        Select
      </button>
      <button type="button" onClick={() => onShare?.(path)}>
        Share
      </button>
      <button type="button" onClick={() => onShare?.(path, "Please review")}>
        Share with a note
      </button>
    </div>
  );
}
  • React and Solid take a callback prop for each event, on and the event's name with a capital (onShare), typed by an exported interface beside the props, FileRowEvents. emit("share", path) calls onShare?.(path).
  • Vue keeps defineEmits and emit, and puts defineEmits right after defineProps, as Vue's macros are ordered.
  • Svelte takes a callback prop in lower case, onshare, joined to the props type.
  • Angular declares an output() for each event, named as the event. Its payload follows the rule below.
  • Qwik takes a lazy callback prop, onShare$. An emit does not wait for it, so the listener may run later.
  • Astro emits nothing: its components render on the server only.

A parent listens as its framework does: onShare in React and Solid, @share in Vue, onshare in Svelte, (share) in Angular and onShare$ in Qwik. Composing .uf.tsx components with each other lands in M3.

Payloads

A payload is a named tuple, so that every target can declare a parameter for each member: [path: string, note?: string]. A member's type is one a prop can have: strings, numbers, booleans, literals, null, undefined, arrays and local object types of them (UF2009). defineEmits takes no runtime arguments and no call signatures, and a component calls it once.

Angular's output() emits one value, so its payload follows a fixed rule:

PayloadAngular's outputIts emit
refresh: []output<void>()refresh.emit()
open: [path: string]output<string>()open.emit(path)
move: [from: string, to: string]output<[from: string, to: string]>()move.emit([from, to])
share: [path: string, note?: string]output<[path: string, note?: string]>()share.emit([path])

A payload of more than one member, or with an optional member, is a tuple of exactly the arguments the emit gives.

Emits and event names

emit is a statement of its own, in client code. Its first argument is a string that names an event the component declares, and it passes as many arguments as the payload takes, none spread (UF2017). It returns nothing: Qwik's listeners may run after the emit, so code never depends on a listener having run.

An event's name is camelCase, in ASCII letters and digits, and starts with a lower-case letter (UF2008):

  • it does not start as an event prop does, onSave: a parent would listen to onOnSave;
  • no two events differ only in case, because Svelte lower-cases an event's prop;
  • no prop has its name, because a prop is public on every target and Angular declares a member for each. A setup binding may: Angular renames its own member, onSave for a function save that emits save, and currentStatus for a state status;
  • it is no keyword of Angular's template expressions (if, as, this, typeof, …), no global an expression may read (parseInt) and not constructor, which Angular's template statements would misread. A JavaScript reserved word, such as delete or new, names an event.

The safe fix renames the event, level-change to levelChange, in its declaration and in every emit of it.

Not yet

  • Listeners on a child component, and on… keys in a spread, land with composition in M3.
  • Form state lands with v-model in M3. Until then, a component reads a field's value from its input event, and clears the field through a template ref.
  • An event of the window or the document is listened to from onMounted, and stopped in onUnmounted (Lifecycle).

The decisions behind this page are ADR-0012, ADR-0017 and ADR-0047.

Copyright © 2026