Back

A look at web components

📖 tl;dr: The great thing about web components is that they can be used in any framework. But the raw API is... weird.

About a week ago, I spent time looking at web components, specifically how we can ease converting Preact components. One of my friends is playing around with them at work and it seemed like a fun challenge to take on. Personally I've never used web components before that. I just didn't have the chance to work on a project which required them so far. So my understanding of them was formed through word of mouth and the usual hot takes that are shared on twitter. But despite that I was geniously curious about them.

To recap: Our preact-custom-element adapter is a small bridge that can turn any Preact component into a web component via a simple function call:

import register from "preact-custom-element";

const Greeting = props => <p>Hello, {props.name}!</p>;
register(Greeting, "x-greeting", ["name"]);

Why web components?

The main benefit of web components is that you can render them from any other framework as well as in plain HTML. This is useful if you're building a design system and have to support a plethora of other frameworks that need to consume your components. You can literally drop a web component into any framework and have them work out of the box!

Despite sounding great on paper there are some head scratching design choices though. The deeper I dive into them the weirder the API feels to me. For granted as a user of preact-custom-element you'll not notice anything of that and I guess this post is more about my first impressions after using for a while.

Style encapsulation

For years, a lot of thought has been spent battling CSS specificity by scoping CSS to certain areas of a page. Typically this involves prefixing CSS selectors with a unique class name and getting rid of the cascade, so that it is more predictable where the CSS will be applied. The solutions range from CSS-Modules, CSS-in-JS to various compiler-based approaches.

Web components solve that on the platform level. Each custom element can have a so-called shadow DOM, which is a DOM tree that is hidden from the page and only accessible from inside the custom element.

Weirdly this is also web components biggest weakness and biggest blocker to use them. Libraries typically don't support shadow DOM natively (can't blame them), which leaves us in a weird spot. The intended solution seems to point to having a compiler that figures out which component needs what kind of styles and let it insert the sheet at the appropriate place.

Without a compiler the solution is more... err gross. It essentially comes down to copying style sheets from the host page into the shadow DOM and attaching a MutationObserver in case they change.

Attributes and Properties

There is this myth going around web dev circles you can pass only strings to custom elements. This stems from the eternal confusion about the difference between attributes and properties on the web. In a nutshell, attributes describe the HTML notation and properties of the JavaScript API.

<!-- foo is an attribute here, only strings allowed -->
<my-custom-element foo="bar"></my-custom-element>

vs properties

const el = document.createElement("my-custom-element");
el.foo = "bar"; // foo is a property, can be anything

The latter allows you to pass any JS value around, whether it is an object, a function or a value of any other type. Declaring an attribute on the other hand is bound to the semantics of HTML and only supports strings. HTML being the natural authoring format of web components it's very understandable how this myth came to be. Even more so when you consider devs having used other templating languages for years which all support complex data types.

As with anything in our industry any problem can be duct-taped around and workarounds emerged quickly. The most common one seems to be to append a special suffix (-json) to each attribute, that serves as a hint to the adapter that the stringified value needs to be parsed.

<my-custom-element foo-json="[1, 2, 3]"></my-custom-element>

While it solves one of the shortcomings of the authoring experience in HTML, I'm a bit on the fence about it admittedly. To be honest though, I don't know of another good solution to this problem, so I can see us adding this in the near future to our adapter via a JSON.parse.

observedAttributes

To support updating components when consumers call el.setAttribute('foo', 123) instead of doing el.foo = 123 the spec expects us to declare which attributes should trigger the update. It's a static property on the custom element class that is an array of strings.

class MyCustomElement extends HTMLElement {
static observedAttributes = ["foo"];
}

Without this our custom element will not update if we call el.setAttribute("foo", 123). From an end user's perspective it's an error prone API to have. It requires the user to keep it in sync with the component implementation itself. We currently require this in our adapter too, but I really want to get rid of it to avoid those pitfalls.

So I was curious as to how this API came to be. The only answers I could find lie this GitHub issue. It seems like the main reason was to avoid traveling the C++ to JS bridge all the time. The style attribute in particular is cited as an example of a very costly update. Scouring various native web component libraries on GitHub it seems like nobody does this though.

As expected, devs quickly found workarounds for this and it's mentioned in the same issue. To avoid having to declare the list of observable attributes by hand, we can leverage a MutationObserver that listens for all attribute changes and we can fire our own update function ourselves.

function observeAttrChange(el, callback) {
var observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
if (mutation.type === "attributes") {
let newVal = mutation.target.getAttribute(mutation.attributeName);
callback(mutation.attributeName, newVal);
}
});
});
observer.observe(el, { attributes: true });
return observer;
}

It may not be what the spec authors originally had in mind, but solves a real problem, that can otherwise only be solved with a compiler. A compiler would know the properties of a component and can declare the observedAttributes property up-front. As an escape hatch we'll probably only use the MutationObserver approach, if the user didn't set observedAttributes by themselves. Time will tell!

Slots are great!

<slots>-nodes are in spirit similar to what you'd do with 'props.children` in a Preact component. They allow you to place nodes into a specific point in your shadow DOM.

<my-custom-element>
<span slot="foo">some content</span>
</my-custom-element>
<!-- shadow DOM -->
<div>
<slot name="foo"><!-- nodes will be placed here --></slot>
</div>

This is a very neat API and I'm impressed how neatly it integrates with HTML. I think the authors did a fantastic job here!

Conclusion

This pretty much sums up my journey into web components so far. It's an interesting aspect of the DOM, albeit being a strange API. Nonetheless, with IE11 finally having an EOL date, it will be interesting to see how it will evolve.

Despite all the controversies, the troubled history, and the rough API, I feel like it's a win for the web. And as usual with Preact we try to support all kinds of use cases as best as we can.