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.
- 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. - Events. The component tells the page with events named
<tag>:<name>(price-list:select), and the data is inevent.detail.addEventListenerworks in every framework; each framework’s own syntax does not always accept the:. - 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.
Summary
Section titled “Summary”| 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
Section titled “React 18”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 and Next.js
Section titled “React 19 and Next.js”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, noplansand 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 attachonprice-list:select(tested): both only take effect on the next render. Withref+ effect andaddEventListener, 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)"/>.propforces 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:selectworks: the template compiles to the listeneronPriceList:select, which Vue attaches to the eventprice-list:select(tested with that listener).addEventListeneron arefalso 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 examplecompilerOptions.isCustomElement: (tag) => tag === "price-list"in the Vue plugin’s config.
Angular
Section titled “Angular”@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_SCHEMAlets 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). UseaddEventListenerafter the first render, as above (tested; the fixture finds the element withquerySelectorinstead ofviewChild).
What was tested, and what was not
Section titled “What was tested, and what was not”- 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 bypackages/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/serverandhydrateRootis the tested equivalent. - The Vue fixture uses render functions; the template and
isCustomElementabove 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.