22
Reactifying Custom Elements using a Custom Elements Manifest
We finally have a stable version of the Custom Elements Manifest schema, and this means we can finally start creating some cool tooling with it. Don't know what a Custom Elements Manifest is? Read all about it in the announcement post.
TL;DR: A Custom Elements Manifest is a JSON file that contains all metadata about the custom elements in your project. You can read all about it here.
React is a bit of a pain when it comes to web components, and (unlike other frameworks) requires a bunch of special handling to support HTML. The TL;DR: here is that React passes all data to Custom Elements in the form of HTML attributes, and can't listen to DOM events due to reinventing the wheel with their own synthetic events system.
For fun and science, I decided to try my hand at creating a @custom-elements-manifest/analyzer plugin to ✨ automagically ✨ create some React wrappers for my custom elements project generic-components, which is a collection of accessible, zero-dependency, vanilla JS web components. Do note that this is mostly a PoC, I'm sure things could be improved and edgecases were missed; this is mostly an experiment to see how we can utilize the Custom Elements Manifest.
In this blog we'll walk through a couple of the steps and decisions to reactify our custom elements, and showcase how we can leverage a projects custom-elements.json
to achieve this goal.
You can read more about @custom-elements-manifest/analyzers rich plugin system here: Plugin Authoring Handbook, and be sure to check out the cem-plugin-template repository.
If you want to follow along, you can find the code for our reactify
plugin here.
First of all, we have to find all the custom elements in our Custom Elements Manifest that we want to reactify. Fortunately, classes in the Manifest that are actually custom elements are flagged with a: "customElement": true
flag, so we can loop through all the modules of our Manifest, and find any class declaration that has the customElement
flag:
const elements = [];
customElementsManifest?.modules?.forEach(mod => {
mod?.declarations?.forEach(dec => {
if(dec.customElement) elements.push(dec);
})
});
Now that we have an array of all the custom elements in our project, we can start creating some React wrappers.
Lets start off easy; slots. Slots are a native way to provide children to your custom elements. Much like React's children
. Which means... we can use children
to project any children of the Reactified component, straight to the Custom Element, which (if it supports slots), will correctly render them.
function GenericSwitch({children}) {
return <generic-switch>{children}</generic-switch>
}
Usage:
<GenericSwitch>Toggle me!</GenericSwitch>
Easy peasy.
Next up: Properties. In React-land, everything gets passed around as a property. This is forms a bit of a problem, because in HTML-land not everything is a property, we also have attributes. Sometimes, an elements attributes and properties are even synced up, and this could mean that there are attributes and properties with the same name; like an element with a disabled
attribute/property or a checked
attribute/property.
Fortunately, in a Custom Elements Manifest we can make a distinction between the two. If an attribute has a relation with a corresponding property, it will have a fieldName
property:
"attributes": [
{
"name": "checked",
"type": {
"text": "boolean"
},
"fieldName": "checked"
},
]
This means that we can ignore the checked
attribute, but interface with the checked
property instead, and avoid having two props with the same name.
Because React will set everything on a custom element as an attribute (ugh), we have to get a ref
for our custom element, and set the property that way. Here's an example:
function GenericSwitch({checked}) {
const ref = useRef(null);
useEffect(() => {
ref.current.checked = checked;
}, [checked]);
return <generic-switch ref={ref}></generic-switch>
}
This is where things get a little bit more interesting. Again, in React-land, everything gets passed around as a property. However, it could be the case that a custom element has an attribute name that is a reserved keyword in JS-land. Here's an example:
<generic-skiplink for="someID"></generic-skiplink>
In HTML, this for
attribute is no problem. But since we're reactifying, and everything in React-land gets passed around as a JavaScript property, we now have a problem. Can you spot what the problem is in this code?
function GenericSkiplink({for}) {
return <generic-skiplink for={for}></generic-skiplink>
}
Exactly. for
is a reserved JavaScript keyword, so this will cause an error. In order to avoid this, we'll provide an attribute mapping to avoid these kinds of clashes:
export default {
plugins: [
reactify({
// Provide an attribute mapping to avoid clashing with React or JS reserved keywords
attributeMapping: {
for: '_for',
},
}),
],
};
Whenever we find an attribute that is a reserved keyword in JavaScript, we try to see if there was an attributeMapping for this attribute provided, and if not; we have to throw an error. Using this attributeMapping, the resulting React component now looks like:
function GenericSkiplink({_for}) {
return <generic-skiplink for={_for}></generic-skiplink>
}
Note that we don't want to change the actual attribute name, because that would cause problems, we only change the value that gets passed to the attribute.
Boolean attributes require some special attention here, as well. The way boolean attributes work in HTML is that the presence of them considers them as being true, and the absence of them considers them as being false. Consider the following examples:
<button disabled></button>
<button disabled=""></button>
<button disabled="true"></button>
<button disabled="false"></button> <!-- Yes, even this is considered as `true`! -->
Calling button.hasAttribute('disabled')
on any of these will result in true
.
This means that for boolean attributes, we can't handle them the same way as regular attributes by only calling ref.current.setAttribute()
, but we need some special handling. Fortunately, the Custom Elements Manifest supports types, so we can easily make a distinction between 'regular' attributes, and boolean attributes:
"attributes": [
{
"name": "checked",
"type": {
+ "text": "boolean"
},
"fieldName": "checked"
},
]
React has their own synthetic event system to handle events, which doesn't play nice with custom elements (read: HTML). Fortunately, we can easily reactify them. React events work with the following convention:
<button onClick={e => console.log(e)}/>
Our Custom Elements Manifest very conveniently holds an array of Events for our custom elements:
"events": [
{
"name": "checked-changed",
"type": {
"text": "CustomEvent"
}
}
],
This means we can find all events for our custom element, prefix them with on
, and capitalize, and camelize them; onCheckedChanged
.
Then we can use our ref
to add an event listener:
function GenericSwitch({onCheckedChanged}) {
const ref = useRef(null);
useEffect(() => {
ref.current.addEventListener("checked-changed", onCheckedChanged);
}, []);
return <generic-switch ref={ref}></generic-switch>
}
Finally, we need to create the import for the actual custom element in our reactified component. Fortunately for us, if a module contains a customElements.define()
call, it will be present in the Manifest. This means we can loop through the Manifest, find where our custom element gets defined, and stitch together some information from the package.json
to create a bare module specifier:
switch.js
:
import { GenericSwitch } from './generic-switch/GenericSwitch.js';
customElements.define('generic-switch', GenericSwitch);
Will result in:
custom-elements.json
:
{
"kind": "javascript-module",
"path": "switch.js",
"declarations": [],
"exports": [
{
"kind": "custom-element-definition",
"name": "generic-switch",
"declaration": {
"name": "GenericSwitch",
"module": "/generic-switch/GenericSwitch.js"
}
}
]
},
By stitching together the name
property from the projects package.json
, and the path
from the module containing the custom element definition, we can construct a bare module specifier for the import:
import '@generic-components/components/switch.js';
To use our @custom-elements-manifest/analyzer Reactify plugin, all I have to do is create a custom-elements-manifest.config.js
in the root of my project, import the plugin, and add it to the plugins
array:
custom-elements-manifest.config.js
:
import reactify from './cem-plugin-reactify.js';
export default {
plugins: [
reactify()
]
};
This means that every time I analyze my project, it will automagically create the Reactified wrappers of my custom elements:
└── legacy
├── GenericAccordion.jsx
├── GenericAlert.jsx
├── GenericDialog.jsx
├── GenericDisclosure.jsx
├── GenericListbox.jsx
├── GenericRadio.jsx
├── GenericSkiplink.jsx
├── GenericSwitch.jsx
├── GenericTabs.jsx
└── GenericVisuallyHidden.jsx
And as a final result, here's our reactified Custom Element that correctly handles:
- Events
- Properties
- Attributes
- Boolean attributes
- Slots
<GenericSwitch
disabled={false} // boolean attribute
checked={true} // property
label={'foo'} // regular attribute
onCheckedChanged={e => console.log(e)} // event
>
Toggle me! // slot
</GenericSwitch>
While it's cool that we finally have a stable version of the Custom Elements Manifest, which allows us to automate things like this, working on this reactify plugin made me realize how backwards it even is that we need to resort to shenanigans like this, and I hope React will seriously consider supporting HTML properly in future versions.
22