Web Components in React 19: Native Support for Custom Elements
For nearly a decade, the relationship between React and standard browser Web Components has been fraught with friction. While standard HTML elements integrated seamlessly into JSX, Custom Elements suffered from major interoperability roadblocks. React historically treated all unrecognized props as HTML attributes rather than DOM properties, and failed to listen to native Custom Events dispatched by Web Components.
As a result, integrating Web Components into React required maintaining cumbersome wrapper layers (like @lit/react), attaching imperative ref handlers, and manually subscribing to event listeners inside useEffect.
With the release of React 19, React officially achieved 100% compliance on the Custom Elements Everywhere benchmark. Developers can now consume Web Components directly in JSX as first-class citizens, passing rich objects as DOM properties and handling custom events declaratively.
In this guide, we explore how React 19 resolves the property-versus-attribute dilemma, how native custom event binding operates, and how to build interoperable design systems without third-party wrappers.
The Historic Problem: Properties vs. Attributes
In the browser DOM, there is a fundamental distinction between HTML attributes and DOM properties:
- Attributes are defined in HTML markup and are strictly strings (e.g.,
<input type="text" value="hello">). - Properties are live JavaScript properties on the DOM node instance and can hold any JavaScript value, including objects, arrays, and functions (e.g.,
element.items = [{ id: 1 }]).
In React 18 and earlier, React unconditionally converted all unknown props on Custom Elements into string attributes:
// Legacy React 18:
<user-card user={{ name: 'Alex', role: 'Admin' }} />
// Rendered in DOM as: <user-card user="[object Object]"></user-card>
Because objects were coerced to "[object Object]", passing structured data to Web Components failed unless you imperatively assigned ref.current.user = ... inside an effect.
How React 19 Fixes Property Binding
React 19 implements an intelligent dual-resolution strategy that works across both client rendering and Server-Side Rendering (SSR) (React documentation):
1. Client-Side Rendering Strategy
When React 19 renders a Custom Element in the browser, it inspects the actual DOM element instance:
- Matches a DOM Property: If the prop name exists on the custom element prototype or instance (
propName in element), React assigns it directly as a DOM property. This allows arrays, objects, functions, and booleans to pass through intact. - Does Not Match: If the prop name does not exist as a property on the element, React sets it as a standard HTML attribute using
setAttribute().
// React 19: Passes complex objects directly to the custom element instance!
<data-table
data={[{ id: 1, name: 'Analytics' }, { id: 2, name: 'Billing' }]}
sortable={true}
/>
2. Server-Side Rendering (SSR) Strategy
Because DOM element instances do not exist on the server, React applies a safe attribute serialization policy:
- Primitives: Props with primitive values (
string,number, or booleantrue) render as standard HTML attributes. - Non-Primitives: Props with non-primitive types (
object,symbol,function, or booleanfalse) are omitted from the static HTML to prevent invalid stringification like"[object Object]". They are subsequently populated when the client hydrates and instantiates the custom element class.
Native Custom Event Listeners
Another major breakthrough in React 19 is native support for Custom Events (MDN documentation).
In earlier versions of React, passing an event handler like onSelectionChange to a custom element did not work because React's synthetic event system only listened for known standard DOM events (like click, change, input).
In React 19, React checks if the prop starts with on and binds a native event listener to the custom element:
'use client';
export function ComponentWrapper() {
function handleFilterApplied(event: CustomEvent<{ category: string }>) {
console.log('Selected category from Web Component:', event.detail.category);
}
return (
<div>
{/* React 19 natively binds to the custom 'filter-applied' event */}
<filter-bar
onfilter-applied={handleFilterApplied}
defaultFilter="all"
/>
</div>
);
}
When <filter-bar> dispatches new CustomEvent('filter-applied', { detail: { category: 'tech' } }), React catches the event and invokes your handler seamlessly.
Practical Implementation: Integrating a Third-Party Web Component
Let us examine a complete implementation using an external Web Component, such as an accessible modal dialog or chart component:
'use client';
import { useState } from 'react';
// Inform TypeScript about custom elements in JSX
declare global {
namespace JSX {
interface IntrinsicElements {
'metric-gauge': React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement> & {
value?: number;
thresholds?: number[];
onthreshold-exceeded?: (e: CustomEvent<{ level: string }>) => void;
},
HTMLElement
>;
}
}
}
export function SystemMonitor({ cpuLoad }: { cpuLoad: number }) {
const [alert, setAlert] = useState<string | null>(null);
return (
<div className="monitor-card">
<h3>System Load</h3>
{/* Direct Web Component consumption in React 19 */}
<metric-gauge
value={cpuLoad}
thresholds={[75, 90, 95]}
onthreshold-exceeded={(e) => setAlert(e.detail.level)}
/>
{alert && <p className="alert-warning">Warning: Threshold {alert} exceeded!</p>}
</div>
);
}
Notice what is absent:
- No
useRefrequired to access the DOM node. - No
useEffectwithelement.addEventListener('threshold-exceeded', ...)and corresponding cleanup functions. - No custom React wrapper packages.
Declarative Shadow DOM Support
For Server-Side Rendered applications, Web Components utilizing Declarative Shadow DOM (MDN documentation) can be rendered directly inside React components:
export function ShadowCard() {
return (
<custom-card>
<template shadowrootmode="open">
<style>{`:host { display: block; padding: 16px; border: 1px solid #ddd; }`}</style>
<slot />
</template>
<p>Card Content rendered inside Shadow DOM</p>
</custom-card>
);
}
The browser parses the template into the component's shadow root immediately upon receiving the server HTML stream, ensuring zero layout shift before client hydration.
Conclusion
With full Custom Elements compliance in React 19, the friction between React and Web Components has been dissolved. By treating custom element properties as real DOM properties and enabling declarative custom event listeners, React 19 makes it easier than ever for cross-framework design systems and web standards to thrive together.