Step-by-step instructions on how to wrap a React component inside a framework-agnostic HTML custom element; how to expose its properties and events, and support children transclusion.
I have a side-project creating and maintaining a React component library called dotNetify-Elements; a very specialized set of UI components that are capable of talking in real-time to a .NET Core back-end through web socket/SignalR.
There’s been a few occasions where I would like to use them on static web pages or websites built with other UI framework. It’s possible, but it entails jumping through a few hoops to get React build system going, and sometimes that just may not be desirable.
The Web Component standard, while not as versatile as React, at least provides a way to build specialized UI elements that can operate natively on most browsers. But building my library again from scratch doesn’t sound too appealing to me, so I went on a trial-and-error experiment to see if I could encapsulate my existing React components into this technology.
It was a successful outcome, to a degree. I haven’t solved every problem; there are some hacks, and I suspect there’s performance hit, but you can see the results here:
(What you will see is the HTML view portion; if you’re interested to see the back-end code too, start here.)
What follows here is a set of instructions that will get you going should you wish to learn how I did it, and how to do it yourself.
There are two steps to creating an HTML custom element:
For example, let’s create one called ‘MyButton’:
class MyButton extends HTMLElement {
constructor() {
super();
}
}
customElements.define('my-button', MyButton);
Now that we have shell, let’s make it so this component will render a React button component when applied to the page.
Just like React, a custom element has some lifecycle hooks that you can access through callback methods. The connectedCallback is invoked when the element is added to the document, while disconnectedCallback is the opposite. These will be a good place to mount and umount our React component:
class MyButton extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
ReactDOM.render(<MyReactComponent />, this);
}
disconnectedCallback(){
ReactDOM.unmountComponentAtNode(this);
}
By using this, we are telling ReactDOM to mount the component on the element, and so any rendered markups will be part of the main document DOM. This is intentional on my part, since I want the component to be affected by the css global theme. But if you want it to be more isolated, then we can mount it on the Shadow DOM instead, which is basically a DOM subtree that’s hidden from other code:
this.root = this.attachShadow({mode: 'open'});
ReactDOM.render(<MyReactComponent />, this.root);
The mode open just means that you still want to allow access to the shadow DOM by Javascript code. Of course, if you want it completely isolated, use the mode closed instead.
Naturally we would like to allow people to pass property values to the React component through attributes on the custom element. The thing is, custom element attributes will be treated as strings, while React properties accept all kinds of types.
Clearly, we need to convert the values to the appropriate types. You can easily write custom code for each element, but let’s opt for a function that does this in a generic manner:
connectedCallback() {
const props = {
...this.getProps(this.attributes, MyReactComponent.propTypes)
};
ReactDOM.render(<MyReactComponent {...props} />, this);
}
getProps(attributes, propTypes) {
propTypes = propTypes|| {};
return [ ...attributes ]
.filter(attr => attr.name !== 'style')
.map(attr => this.convert(propTypes, attr.name, attr.value))
.reduce((props, prop) =>
({ ...props, [prop.name]: prop.value }), {});
}
convert(propTypes, attrName, attrValue) {
const propName = Object.keys(propTypes)
.find(key => key.toLowerCase() == attrName);
let value = attrValue;
if (attrValue === 'true' || attrValue === 'false')
value = attrValue == 'true';
else if (!isNaN(attrValue) && attrValue !== '')
value = +attrValue;
else if (/^{.*}/.exec(attrValue))
value = JSON.parse(attrValue);
return {
name: propName ? propName : attrName,
value: value
};
}
The attributes of a custom element is accessible from this.attributes. What the function does is to iterate through the attributes, guess the type by looking at its value, find the matching React property name from the component’s propTypes (since attribute names will all be lower-case), guess the type by looking at its value, convert and aggregate them as object literal to be passed to the React component.
We now have the capability to set React property values through its custom element attributes, but what if we want to change any of the values through Javascript after the element is rendered?
What we need is a mechanism to notify our element that any of its attributes has changed, so it can force the React component to update. Custom element does have another callback method that deals with this type of event, aptly named attributeChangedCallback. But alas, this callback will work only if we add getObservedAttributes static function that returns the attribute names, which we’ll have to declare statically.
I’m looking for a solution that won’t force me to list all the attributes I want to observe, and luckily there’s something called MutationObserver. This is part of DOM event specification that allows us to watch changes made to the DOM tree.
Let’s update the element’s constructor to use this API to listen to any attribute change, and to force the React component to re-mount when that happens. In the process, we refactor the React mount and dismount code into separate methods for reusability:
constructor() {
super();
this.observer = new MutationObserver(() => this.update());
this.observer.observe(this, { attributes: true });
}
connectedCallback() {
this.mount();
}
disconnectedCallback() {
this.unmount();
this.observer.disconnect();
}
update() {
this.unmount();
this.mount();
}
mount() {
const props = {
...this.getProps(this.attributes, MyReactComponent.propTypes)
};
ReactDOM.render(<MyReactComponent {...props} />, this);
}
unmount() {
ReactDOM.unmountComponentAtNode(this);
}
Our button element will be useless without the ability to raise events. So the next thing to work out is how to turn any event that’s raised by the React component into DOM event that can be listened to with addEventListener.
Assuming the component is following React event naming convention (camel-case on*), we will again use propTypes to look for property names matching the pattern and pass it a function that will call DOMdispatchEvent:
mount() {
const props = {
...this.getProps(this.attributes, MyReactComponent.propTypes)
...this.getEvents(MyReactComponent.propTypes)
};
ReactDOM.render(<MyReactComponent {...props} />, this);
}
getEvents(propTypes) {
return Object.keys(propTypes)
.filter(key => /on([A-Z].*)/.exec(key))
.reduce((events, ev) => ({
...events,
[ev]: args =>
this.dispatchEvent(new CustomEvent(ev, { ...args }))
}), {});
}
Here’s a screenshot of what we have so far, a button element that responds to a click event and attribute change:
While this implementation is good for leaf components, I have cases where my React component is a container to other elements, whether it’s simple scalar value, DOM elements, or even other React components. This proved quite a challenge to figure out as ReactDOM render seems to only want to accept React component as children, while I needed it to accept the inner HTML markups of the custom element.
I finally settled on using the library html-to-react, which takes HTML markups and produces a React component. Let’s update our code to this:
import htmlToReact from 'html-to-react';
...
mount() {
const props = {
...this.getProps(this.attributes, MyReactComponent.propTypes)
...this.getEvents(MyReactComponent.propTypes),
children: this.parseHtmlToReact(this.innerHTML)
};
ReactDOM.render(<MyReactComponent {...props} />, this);
}
parseHtmlToReact(html) {
return html && new htmlToReact.Parser().parse(html);
}
The custom element is now able to render nested elements. However, as soon as the element gets updated, they disappear. Why? It’s because the content of this.innerHTML is gone after the element is mounted. To overcome this, let’s cache the initial content during connectedCallback and use the variable inside mount:
connectedCallback() {
this._innerHTML = this.innerHTML;
this.mount();
}
And that’s it! That’s the gist of it. You can go beyond this, like I did. For my components I implemented a lot more to handle my very-specific use cases, like context injection and the management of that reactive form you see the demo of.
Thank’s for reading!
<a href="https://medium.com/media/3c851dac986ab6dbb2d1aaa91205a8eb/href">https://medium.com/media/3c851dac986ab6dbb2d1aaa91205a8eb/href</a>