In my role as a web developer who sits at the intersection of design and code, I am drawn to Web Components because of their portability. It makes sense: custom elements are fully-functional HTML elements that work in all modern browsers, and the shadow DOM encapsulates the right styles with a decent surface area for customization. It’s a really nice fit, especially for larger organizations looking to create consistent user experiences across multiple frameworks, like Angular, Svelte and Vue.
In my experience, however, there is an outlier where many developers believe that custom elements don’t work, specifically those who work with React, which is, arguably, the most popular front-end library out there right now. And it’s true, React does have some definite opportunities for increased compatibility with the web components specifications; however, the idea that React cannot integrate deeply with Web Components is a myth.
In this article, I am going to walk through how to integrate a React application with Web Components to create a (nearly) seamless developer experience. We will look at React best practices its and limitations, then create generic wrappers and custom JSX pragmas in order to more tightly couple our custom elements and today’s most popular framework.
Coloring in the lines
If React is a coloring book — forgive the metaphor, I have two small children who love to color — there are definitely ways to stay within the lines to work with custom elements. To start, we’ll write a very simple custom element that attaches a text input to the shadow DOM and emits an event when the value changes. For the sake of simplicity, we’ll be using LitElement as a base, but you can certainly write your own custom element from scratch if you’d like.
Our super-cool-input
element is basically a wrapper with some styles for a plain ol’ <input>
element that emits a custom event. It has a reportValue
method for letting users know the current value in the most obnoxious way possible. While this element might not be the most useful, the techniques we will illustrate while plugging it into React will be helpful for working with other custom elements.
Approach 1: Use ref
According to React’s documentation for Web Components, “[t]o access the imperative APIs of a Web Component, you will need to use a ref to interact with the DOM node directly.”
This is necessary because React currently doesn’t have a way to listen to native DOM events (preferring, instead, to use it’s own proprietary SyntheticEvent
system), nor does it have a way to declaratively access the current DOM element without using a ref.
We will make use of React’s useRef
hook to create a reference to the native DOM element we have defined. We will also use React’s useEffect
and useState
hooks to gain access to the input’s value and render it to our app. We will also use the ref to call our super-cool-input
’s reportValue
method if the value is ever a variant of the word “rad.”
One thing to take note of in the example above is our React component’s useEffect
block.
useEffect(() => {
coolInput.current.addEventListener('custom-input', eventListener);
return () => {
coolInput.current.removeEventListener('custom-input', eventListener);
}
});
The useEffect
block creates a side effect (adding an event listener not managed by React), so we have to be careful to remove the event listener when the component needs a change so that we don’t have any unintentional memory leaks.
While the above example simply binds an event listener, this is also a technique that can be employed to bind to DOM properties (defined as entries on the DOM object, rather than React props or DOM attributes).
This isn’t too bad. We have our custom element working in React, and we’re able to bind to our custom event, access the value from it, and call our custom element’s methods as well. While this does work, it is verbose and doesn’t really look like React.
Approach 2: Use a wrapper
Our next attempt at using our custom element in our React application is to create a wrapper for the element. Our wrapper is simply a React component that passes down props to our element and creates an API for interfacing with the parts of our element that aren’t typically available in React.
Here, we have moved the complexity into a wrapper component for our custom element. The new CoolInput
React component manages creating a ref while adding and removing event listeners for us so that any consuming component can pass props in like 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>;
}
On this component, we have created a prop, onCustomInput
, that, when present, triggers an event callback from the parent component. Unlike a normal event callback, we chose to add a second argument that passes along the current value of the CoolInput
’s internal ref.
Using these same techniques, it is possible to create a generic wrapper for a custom element, such as this reactifyLitElement
component from Mathieu Puech. This particular component takes on defining the React component and managing the entire lifecycle.
Approach 3: Use a JSX pragma
One other option is to use a JSX pragma, which is sort of like hijacking React’s JSX parser and adding our own features to the language. In the example below, we import the package jsx-native-events from Skypack. This pragma adds an additional prop type to React elements, and any prop that is prefixed with onEvent
adds an event listener to the host.
To invoke a pragma, we need to import it into the file we are using and call it using the /** @jsx <PRAGMA_NAME> */
comment at the top of the file. Your JSX compiler will generally know what to do with this comment (and Babel can be configured to make this global). You might have seen this in libraries like Emotion.
An <input>
element with the onEventInput={callback}
prop will run the callback
function whenever an event with the name 'input'
is dispatched. Let’s see how that looks for our super-cool-input
.
The code for the pragma is available on GitHub. If you want to bind to native properties instead of React props, you can use react-bind-properties. Let’s take a quick look at that:
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])
}
Essentially, this code converts any existing props with the onEvent
prefix and transforms them to an event name, taking the value passed to that prop (ostensibly a function with the signature (e: Event) => void
) and adding it as an event listener on the element instance.
Looking forward
As of the time of this writing, React recently released version 17. The React team had initially planned to release improvements for compatibility with custom elements; unfortunately, those plans seem to have been pushed back to version 18.
Until then it will take a little extra work to use all the features custom elements offer with React. Hopefully, the React team will continue to improve support to bridge the gap between React and the web platform.