Web components in React work without a wrapper from React 19 onwards. React sets a value as a property when the custom element defines one, falls back to an attribute when it does not, and registers a listener for any custom event you bind with an on prefix. Custom Elements Everywhere scores React ^19 at 100%, 16 of 16 basic tests and 16 of 16 advanced.
That is the headline. The detail is where teams still get stuck, and two of the three sharp edges have nothing to do with React’s DOM handling.
Why did React fight custom elements for so long?
React 18 and earlier treated any prop it did not recognise as an attribute, and attributes are strings. Pass an object to <my-chart data={rows} /> and the element received the string [object Object]. Pass an array of numbers and it received 1,2,3.
Events were the second half of the problem. React’s synthetic event system knows a fixed list of DOM events, so an element dispatching new CustomEvent('series-click') had no matching on* prop. You reached for a ref instead.
function Chart({ rows, onSeriesClick }) {
const ref = useRef<HTMLElement>(null);
useEffect(() => {
if (ref.current) (ref.current as any).data = rows; // property, not attribute
}, [rows]);
useEffect(() => {
const el = ref.current;
if (!el || !onSeriesClick) return;
const handler = (e: Event) => onSeriesClick((e as CustomEvent).detail);
el.addEventListener('series-click', handler);
return () => el.removeEventListener('series-click', handler);
}, [onSeriesClick]);
return <my-chart ref={ref} />;
}
Roughly that, generated per element, is what every web component wrapper library exists to write for you.
How does React 19 decide between a property and an attribute?
Two different rules, one for the browser and one for the server. The React 19 release post states them plainly. On the client, “props that match a property on the Custom Element instance will be assigned as properties, otherwise they will be assigned as attributes”. On the server, props render as attributes if they are a primitive such as a string or number, or the value true; props typed as object, symbol or function, or with the value false, are omitted.
So this JSX behaves four different ways in one element:
<my-chart
heading="Revenue"
animated={true}
data={[{ x: 1, y: 4 }]}
onseriesclick={handleClick}
/>
heading is a string, so it appears as heading="Revenue" in the server HTML and is then set as a property on the client if MyChart declares one. animated renders as animated=""; had it been false it would be missing from the HTML entirely, which matters if your element reads the attribute in connectedCallback. data never reaches the server output at all and is applied as a property after hydration. onseriesclick binds addEventListener('seriesclick', handleClick).
Note that last one. React strips the on and uses the remainder verbatim as the event name, and Custom Elements Everywhere records that it “supports lowercase, camelCase, kebab-case, CAPScase, and PascalCase events”. If your element dispatches series-click, the prop is on-series-click, not onSeriesClick. Getting this wrong fails silently: no error, no listener, just a button that does nothing.
What breaks when the element is not defined yet?
The property check runs against the live instance at the moment React commits. If customElements.define('my-chart', MyChart) has not executed by then, the element has none of its properties, so every prop goes down the attribute path. Your data array becomes the attribute data="[object Object]".
React issue #29037 tracks the gap between this and the original design, which was to skip complex values rather than stringify them. As shipped, only functions and symbols are omitted; other complex values are coerced. Passing a collection containing a symbol throws at runtime.
The fix is ordering, not cleverness. Import your element definitions at module scope in the file that renders them, so the side effect runs before React’s first pass. If the design system is code-split, hold the subtree back until the tag exists:
import { useEffect, useState } from 'react';
export function useDefined(tag: string): boolean {
const [ready, setReady] = useState(() => Boolean(customElements.get(tag)));
useEffect(() => {
if (ready) return;
let live = true;
customElements.whenDefined(tag).then(() => {
if (live) setReady(true);
});
return () => {
live = false;
};
}, [tag, ready]);
return ready;
}
Render a skeleton while ready is false. It costs one paint and removes an entire class of bug report that always arrives worded as “it works on my machine after a hot reload”.
How do you type a custom element in React 19 and TypeScript?
Every older answer to this question is now wrong. React 19 removed the global JSX namespace in favour of React.JSX, so a declare global { namespace JSX { ... } } block is quietly ignored. The upgrade guide says to wrap the augmentation in declare module, and which module depends on your jsx compiler option: react/jsx-runtime for react-jsx, react/jsx-dev-runtime for react-jsxdev, and react for react or preserve.
// custom-elements.d.ts
import type { DetailedHTMLProps, HTMLAttributes } from 'react';
import type { MyChart } from '@acme/elements';
type MyChartProps = DetailedHTMLProps<HTMLAttributes<MyChart>, MyChart> & {
heading?: string;
animated?: boolean;
data?: Array<{ x: number; y: number }>;
onseriesclick?: (event: CustomEvent<{ index: number }>) => void;
};
declare module 'react/jsx-runtime' {
namespace JSX {
interface IntrinsicElements {
'my-chart': MyChartProps;
}
}
}
The change ships in the react-19 codemod preset as scoped-jsx, so an existing project with several of these files can be converted mechanically. If you are moving the rest of the toolchain at the same time, our notes on migrating a tsconfig to TypeScript 7 cover the flags that tend to break alongside it.
Do web components in React still need a wrapper?
Sometimes, and the deciding factor is who writes the JSX.
An application team consuming eight elements from an internal library can drop @lit/react and use the tags directly. The property handling is native now, the events bind, and one .d.ts file gives them autocomplete.
A team publishing a design system to React consumers they do not control should keep the wrapper. createComponent still buys a real component boundary and typed event props, where the EventName utility gives a callback typed as your event rather than a bare Event:
import React from 'react';
import {createComponent} from '@lit/react';
import {MyElement} from './my-element.js';
export const MyElementComponent = createComponent({
tagName: 'my-element',
elementClass: MyElement,
react: React,
events: {
onactivate: 'activate',
onchange: 'change',
},
});
The wrapper also lets you rename events into idiomatic React props, which spares consumers the on + verbatim name rule and its silent failure mode.
What happens on the server and with shadow DOM?
A custom element upgrade needs JavaScript, so server rendering gives you the tag and its primitive attributes and nothing inside. The user sees an empty box until the definition loads. React does not serialise an element’s shadow root for you, and it has no way to, because the shadow content is created by the element’s own constructor.
Declarative shadow DOM is the mechanism that closes the gap: a <template shadowrootmode="open"> inside the tag, which the HTML parser turns into a real ShadowRoot with no script involved. MDN documents the companion attributes too, shadowrootdelegatesfocus, shadowrootclonable and shadowrootserializable. That markup has to come from the element library’s own server-rendering tooling, not from React.
Budget for the hydration either way. Element definitions are usually one more bundle that has to parse and execute before anything responds to a click, which lands in the same budget as everything else in our guide to diagnosing INP.
What I would do today: on React 19, use the tags directly inside an application, keep @lit/react for anything published outward, and write the .d.ts on day one rather than after the third any. On React 18 the wrapper remains the only sane option, which is a decent reason on its own to schedule the upgrade. I would still not rebuild an app’s whole component layer as custom elements for portability nobody asked for. But when the same button has to work in a React app, an Astro marketing site and a page some other team maintains, this is now the cheapest thing that works, and it is the shape we reach for when we build React front ends that outlive their first framework.