DEV Community

Pascal Schilp
Pascal Schilp

Posted on • Edited on

Reactifying Custom Elements using a Custom Elements Manifest

🚨 Since creating the PoC of the plugin in this blogpost, Burton Smith has created a much better version of the plugin demonstrated in this blogpost, it's more feature complete, well maintained, and you should use his plugin instead 🙂 You can find his plugin here.

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.

Custom Elements

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);
  })
});
Enter fullscreen mode Exit fullscreen mode

Now that we have an array of all the custom elements in our project, we can start creating some React wrappers.

Slots

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>
}
Enter fullscreen mode Exit fullscreen mode

Usage:

<GenericSwitch>Toggle me!</GenericSwitch>
Enter fullscreen mode Exit fullscreen mode

Easy peasy.

Properties

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"
    },
  ]
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

Attributes

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>
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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',
      },
    }),
  ],
};
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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

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`! -->
Enter fullscreen mode Exit fullscreen mode

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"
    },
  ]
Enter fullscreen mode Exit fullscreen mode

Events

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)}/>
Enter fullscreen mode Exit fullscreen mode

Our Custom Elements Manifest very conveniently holds an array of Events for our custom elements:

  "events": [
    {
      "name": "checked-changed",
      "type": {
        "text": "CustomEvent"
      }
    }
  ],
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

Importing

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);
Enter fullscreen mode Exit fullscreen mode

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"
      }
    }
  ]
},
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

Using the plugin

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()
  ]
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Result

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>
Enter fullscreen mode Exit fullscreen mode

Concluding

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.

Top comments (1)

Collapse
 
hsablonniere profile image
Hubert SABLONNIÈRE

This is so nice :p