A Pragmatic Look at React and Web Components
Web Components and custom elements have earned a solid reputation for portability. They are fully functional HTML elements that work across all modern browsers, and the shadow DOM provides style encapsulation with a decent surface area for customization. That makes them attractive for larger organizations that want consistent user experiences across different frameworks, such as Angular, Svelte or Vue.
React, however, has long been viewed as the odd one out. It is true that React's compatibility with the web components specifications has room for improvement, but the notion that React cannot integrate deeply with custom elements is a myth. It just takes a bit of extra work. Below are three practical approaches to bridging that gap, illustrated with a simple super-cool-input element built on LitElement. The element wraps a plain <input>, emits a custom event when its value changes, and exposes a reportValue method.
Approach 1: Direct DOM Access via Refs
React's own documentation on Web Components recommends using a ref to interact with the DOM node directly when you need to access imperative APIs. This is necessary because React does not currently listen to native DOM events, preferring its proprietary SyntheticEvent system instead. Refs are also the only declarative way to get a handle on the actual DOM element.
Using the useRef, useEffect and useState hooks, we can bind an event listener to our custom element, read its value and render it into the app. The ref also gives us access to the element's methods, such as calling reportValue when the input matches a specific condition.
useEffect(() => {
coolInput.current.addEventListener('custom-input', eventListener);
return () => {
coolInput.current.removeEventListener('custom-input', eventListener);
}
});
Note the useEffect block in that example. It creates a side effect by adding an event listener that React does not manage, so cleanup is required to remove the listener when the component updates. This prevents unintentional memory leaks. The same pattern works for binding to DOM properties (entries on the DOM object, rather than React props or attributes).
This approach works, but it is verbose and reads less like idiomatic React.
Approach 2: Encapsulate the Complexity in a Wrapper
The second approach moves the boilerplate into a wrapper React component. The wrapper—called CoolInput—manages the ref and handles adding and removing event listeners internally. Consumer components then pass props as they would with any other React component.
function CoolInput(props) {
const ref = useRef();
const { children, onCustomInput, ...rest } = props;
function invokeCallback(event) {
if (onCustomInput) {
onCustomInput(event, ref.current);
}
}
useEffect(() => {
const { current } = ref;
current.addEventListener('custom-input', invokeCallback);
return () => {
current.removeEventListener('custom-input', invokeCallback);
}
});
return <super-cool-input ref={ref} {...rest}>{children}</super-cool-input>;
}
In this version, a custom onCustomInput prop triggers a callback from the parent. Unlike a standard event callback, it passes a second argument containing the current value from the internal ref. This same technique can be generalized into a reusable wrapper. A solid example is the reactifyLitElement helper, which defines the React component and manages its full lifecycle for any given LitElement.
Approach 3: Extend JSX with a Custom Pragma
A more advanced option is to use a JSX pragma to augment React's JSX parser. The jsx-native-events package, for instance, adds a special prop convention: any prop prefixed with onEvent is treated as a native event listener on the host element rather than a React synthetic event.
Using a pragma requires importing it into the file and declaring it with a /** @jsx <PRAGMA_NAME> */ comment at the top. The JSX compiler picks up on this comment, and Babel can be configured to make it global. This pattern is familiar to anyone who has used libraries like Emotion. With this in place, an element like <input> with an onEventInput={callback} prop will run the callback whenever an input event is dispatched on that element.
The pragma essentially converts props with the onEvent prefix into event names and registers the provided callback as a listener on the element instance.
import React from 'react'
/**
* Convert a string from camelCase to kebab-case
* @param {string} string - The base string (ostensibly camelCase)
* @return {string} - A kebab-case string
*/
const toKebabCase = string => string.replace(/([a-z0-9]|(?=[A-Z]))([A-Z])/g, '$1-$2').toLowerCase()
/** @type {Symbol} - Used to save reference to active listeners */
const listeners = Symbol('jsx-native-events/event-listeners')
const eventPattern = /^onEvent/
export default function jsx (type, props, ...children) {
// Make a copy of the props object
const newProps = { ...props }
if (typeof type === 'string') {
newProps.ref = (element) => {
// Merge existing ref prop
if (props && props.ref) {
if (typeof props.ref === 'function') {
props.ref(element)
} else if (typeof props.ref === 'object') {
props.ref.current = element
}
}
if (element) {
if (props) {
const keys = Object.keys(props)
/** Get all keys that have the `onEvent` prefix */
keys
.filter(key => key.match(eventPattern))
.map(key => ({
key,
eventName: toKebabCase(
key.replace('onEvent', '')
).replace('-', '')
})
)
.map(({ eventName, key }) => {
/** Add the listeners Map if not present */
if (!element[listeners]) {
element[listeners] = new Map()
}
/** If the listener hasn't be attached, attach it */
if (!element[listeners].has(eventName)) {
element.addEventListener(eventName, props[key])
/** Save a reference to avoid listening to the same value twice */
element[listeners].set(eventName, props[key])
}
})
}
}
}
}
return React.createElement.apply(null, [type, newProps, ...children])
}
For binding to native DOM properties rather than React props, the related react-bind-properties package follows a similar idea.
What Comes Next
React 17 shipped without the improved custom element compatibility the React team had originally scoped. Those plans have been pushed back, with related issues still open in the React repository and work expected to land in version 18.
Until that support arrives, integrating custom elements with React requires one of these workarounds. Each approach has its trade-offs between verbosity, developer experience and how closely the final code resembles standard React. All of them, however, keep custom elements fully usable within a React application today.



