Pular para o conteúdo

Components in React, Vue and Angular

Este conteúdo não está disponível em sua língua ainda.

A Taggor component is a custom element (<price-list>). To your framework it is an HTML tag, like <video>. Three things change from framework to framework.

  1. Props. An HTML attribute carries only text. A text, number or boolean prop works as an attribute (heading="Plans"). A list or an object (plans) has to go as a property of the element (element.plans = [...]); as an attribute it becomes the text "[object Object]", the component refuses it and uses the default value.
  2. Events. The component tells the page with events named <tag>:<name> (price-list:select), and the data is in event.detail. addEventListener works in every framework; each framework’s own syntax does not always accept the :.
  3. When the component arrives. The Taggor script is asynchronous: usually the tag is defined after your app has rendered. The element is already on the page and is activated in place when the component arrives; a property you set before is not lost.
Framework Complex prop (plans) Event price-list:select Component arrives after the app SSR
React 18 ref + effect (el.plans = plans) addEventListener on the ref Works n/a
React 19 Direct (plans={plans}) only if the tag is already defined; otherwise ref + effect addEventListener on the ref, or the prop onprice-list:select Direct: fails until the next render. ref: works Hydration sets neither the direct prop nor onprice-list:select: use ref + effect and addEventListener
Vue 3 :plans.prop="plans" @price-list:select or addEventListener Works (with .prop) n/a
Angular [plans]="plans" with CUSTOM_ELEMENTS_SCHEMA addEventListener (the template’s (price-list:select) does not work) Works n/a

In all of them the app can re-render freely. While the tag stays in the same place of the tree, the framework reuses the same element and the component only receives the new props, keeping its own state (tested). What remounts the component, and clears its state, is taking the tag out of the tree and putting it back: changing the key (React), toggling with v-if (Vue) or @if (Angular).

React 18 passes every prop of a custom element as an attribute and has no syntax for an event with :. The recipe: a ref, the complex prop in an effect and the event with addEventListener.

import { useEffect, useRef, useState } from "react";
export function Plans({ plans }: { plans: Plan[] }) {
const ref = useRef<HTMLElement & { plans?: Plan[] }>(null);
const [selected, setSelected] = useState<string>();
// Complex prop: a property, on every change.
useEffect(() => {
if (ref.current) ref.current.plans = plans;
}, [plans]);
// Event with ":": addEventListener, with cleanup.
useEffect(() => {
const element = ref.current;
if (!element) return;
const onSelect = (event: Event) => setSelected((event as CustomEvent).detail.id);
element.addEventListener("price-list:select", onSelect);
return () => element.removeEventListener("price-list:select", onSelect);
}, []);
return <price-list ref={ref} heading="Plans" />;
}

A text prop (heading) goes as an attribute, the normal way. It works with the component defined before or after the first render (tested both ways).

React 19 sets the prop of a custom element as a property when the element already has it ("plans" in element), that is, when the tag was defined before React created the element.

<price-list heading="Plans" plans={plans} />

This works if the component is already defined (tested). But the Taggor script usually arrives later. Then React 19 writes the attribute plans="[object Object],[object Object]", the component shows the empty list (the default) and only receives the plans on the next render of your React component (tested). So for a complex prop, use the React 18 recipe (ref + effect), which works either way (tested).

Event. Besides addEventListener, React 19 understands a prop of on + the event name on a custom element: onprice-list:select listens to price-list:select (tested). In JSX, the : in the name asks for a spread: <price-list {...{ "onprice-list:select": onSelect }} />.

SSR (Next.js). Tested with a page equivalent to a Next.js one: the server renders the app with react-dom/server (renderToString) and the browser hydrates with hydrateRoot, React 19 in development mode (which shows hydration warnings). The Next.js build itself was not used for this test; the equivalent page tests the same thing without it. (The compatibility matrix does run the components on a real Next.js App Router export, for rendering, props, events and slots.)

  • The server sends the tag with only the text props (<price-list heading="Round 1">), no shadow root, no plans and no listener. The component mounts only in the browser. So there is no hole until then, use fallback content.
  • Hydration gives no warning of a mismatch: React does not compare the shadow root the component creates (tested, with the component defined before and after the hydration, and with a control that changes a server text and must warn).
  • Hydration does not set the direct prop plans={plans} and does not attach onprice-list:select (tested): both only take effect on the next render. With ref + effect and addEventListener, everything works from the hydration on (tested). In Next.js (App Router), that code goes in a React component with "use client".
<price-list
heading="Plans"
:plans.prop="plans"
@price-list:select="(event) => (selected = event.detail.id)"
/>
  • .prop forces the property even before the component arrives. Without it, Vue uses the property only when "plans" in element; before the definition, it writes the attribute.
  • @price-list:select works: the template compiles to the listener onPriceList:select, which Vue attaches to the event price-list:select (tested with that listener). addEventListener on a ref also works (tested).
  • Not tested here (the fixture uses render functions, without the template compiler): so that Vue does not look for a Vue component named price-list, declare the tag as a native element, for example compilerOptions.isCustomElement: (tag) => tag === "price-list" in the Vue plugin’s config.
@Component({
selector: "app-plans",
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: `<price-list #list heading="Plans" [plans]="plans()"></price-list>`,
})
export class PlansComponent {
readonly plans = signal<Plan[]>([]);
readonly selected = signal<string | undefined>(undefined);
private readonly list = viewChild.required<ElementRef<HTMLElement>>("list");
constructor() {
const onSelect = (event: Event) => this.selected.set((event as CustomEvent).detail.id);
afterNextRender(() =>
this.list().nativeElement.addEventListener("price-list:select", onSelect),
);
inject(DestroyRef).onDestroy(() =>
this.list().nativeElement.removeEventListener("price-list:select", onSelect),
);
}
}
  • CUSTOM_ELEMENTS_SCHEMA lets the template use a tag Angular does not know.
  • [plans] is a property binding: Angular always sets the property, with the component defined or not (tested both ways).
  • (price-list:select) does not work: Angular reads the : as a global target (window:, document:, body:) and stops with the error “Unexpected global target ‘price-list’ defined for ‘select’ event” (verified). Use addEventListener after the first render, as above (tested; the fixture finds the element with querySelector instead of viewChild).
  • Tested in Chromium only, with React 18.3.1, React 19.3.1, Vue 3.5.44 and Angular 22.2.3, in the fixtures packages/runtime/e2e/hosts/ run by packages/runtime/e2e/frameworks.e2e.ts. The browser matrix runs the components on a React single-page app and a Next.js export, not on Vue or Angular hosts.
  • Next.js itself was not run for the SSR test: the page with react-dom/server and hydrateRoot is the tested equivalent.
  • The Vue fixture uses render functions; the template and isCustomElement above follow Vue’s documentation.
  • The Angular fixture uses the JIT compiler, without the Angular CLI; in an app with the CLI (AOT) the template and the bindings are the same.
  • Typed wrappers per framework (<PriceList plans={...} onSelect={...} />) are not built yet; this guide is what works today without them.