# Components in React, Vue and Angular

> How to pass props, listen to events and handle a late-loading script when you use a Taggor component inside a React, Next.js, Vue or Angular app.

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.

## 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

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`.

```tsx
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

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.

```tsx
<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](https://taggor.dev/docs/fallback.md).
- 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"`.

## Vue 3

```vue
<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.

## Angular

```ts
@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`).

## 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 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.
