Despite the frontend fatigue, there is one abstraction that spread like wildfire in the last decade: Components.
MVC, MVVM, MVP and so many others that ruled over the years, but they were sidelined by this ‘convention’. Today every mainstream frontend framework, especially in the web platform, uses the components as their main form of composition. It would not be outrageous to say that its influence past the boundaries of the web platform and spread to iOS with SwiftUI.
The next logical step is understandably defining a component standard for the web, which is supported by the browsers.
There are many standard element types in web: img, a, p, div, aside, time. Some only represent a semantic meaning (like main or label) and some has functional purpose (video, audio, picture etc.). What if we can define our own elements? That’s the whole premise of the web components.
Web components are custom HTML elements that represents custom functionality in the runtime.
That sounds wonderful! I won’t need a framework anymore to build highly composable UIs. How do I start?
First of all, we need to define a class. A class that defines the component. We should also find an appropriate name with a hyphen -. That's the convention.
Let’s start with a counter component:
class Counter extends HTMLElement {
_count = 0;
get count() {
return this._count;
}
set count(val) {
this._count = val;
this.render();
}
render() {
this.countSpan.innerText = this.count.toString();
}
increment = () => {
this.count += 1;
};
decrement = () => {
this.count -= 1;
};
connectedCallback() {
// This is a member function for the web component
// It is invoked when an instance is mounted
this.getElementById("#increment").addEventListener("click", this.increment);
this.getElementById("#decrement").addEventListener("click", this.decrement);
this.countSpan = this.getElementById("#count");
}
}
customElements.define("my-counter", Counter);
And in the markup:
<my-counter>
<button id="increment">+</button>
<button id="decrement">-</button>
<span id="count">0</span>
</my-counter>
There is a lot of boilerplate. But, it’s fine I guess.
Now show me how to set up an initial value!
Before I can do that, I need to explain the attributes. First degree cousin of properties. With attributes, we can change the behavior of an HTML element. For example, href attribute defines the target url of an anchor tag. Or style attribute can change how an element is rendered. There are also boolean attributes like disabled, which has no value.
For web components, we can define new attributes. Let’s create one called value:
class Counter extends HTMLElement {
// ...
connectedCallback() {
// ...
// Don't parse the string to an int like this in prod
this.count = Number.parseInt(this.getAttribute("value") ?? "0");
}
static observedAttributes = ["value"];
attributeChangedCallback(name, oldValue, newValue) {
// This member function of the web component is a callback for attribute changes
if (name === "value") {
this.count = newValue;
}
}
// ...
}
I kinda like it. But, wait! I see the counter flickers from 0 to 1 when I set the value to 1. What’s going on?
There is mismatch between the initial markup and the updated markup. In React’s terms, there is a hydration issue 😅
When the browser loads the page, first it renders the markup. And when the component mounts (hydrates), the connectedCallback is called and the markup is updated with the value attribute. value attribute and the count span should match to prevent that to happen:
<my-counter value="1">
<button id="increment">+</button>
<button id="decrement">+</button>
<span id="count">1</span>
</my-counter>
It all makes sense. But, hold on! How am I going to render the initial markup?
Well… You can write your render functions for your server runtime 🫢
So you are telling me that I need to maintain a render function for both runtimes?
This can be a deal breaker.
Wait, web components can do a lot more. Let’s look at composition. In their purest form, web components are not that different from the standard elements. You can think of them as wrappers whose responsibility is to manage their children. But the web components unlock new capabilities when they have a shadow DOM.
That sounds… a bit tacky. What does shadow DOM do?
The main goal is to create an isolated DOM tree that is (kinda) hidden away from the root document. It is like a light-iframe.
When a shadow DOM is attached to a web component instance, it is encapsulated and independent from the outer DOM tree. This also includes CSSOM, in other words styling. For instance, the CSS selector p > img.profile { border: none; } won’t match anything inside shadow DOM, unless you specify the same rule for this shadow DOM too. Styling-wise they are isolated.
Attaching a shadow DOM to a web component is fairly easy:
class Counter extends HTMLElement {
// ...
connectedCallback() {
const shadow = this.attachShadow({ mode: "open" });
// ...
}
// ...
}
Hold on! What is this mode: 'open' thing over there?
This might sound a tad complicated, but you remember that I said the shadow DOM is isolated: That is partially true. The shadow DOM has two modes: open and closed. When it is closed, the shadow DOM content is not accessible from the root document. It becomes a black box from the perspective of the document.
When it is open, a shadow DOM can be accessed in JS runtime via shadowRoot attribute. You might say that shadowRoot is the root node of the shadow DOM.
What about the styles? Do they somehow get different treatment when they are open or close?
That’s the confusing part. Shadow DOM mode only affects the DOM access pattern. Styling rules are always isolated and there are no ways of writing CSS selectors that target shadow DOM elements (sorta).
Hope it is understood what shadow DOM is and how it behaves. We can start using it to see what kind of difference it makes with our counter example.
class Counter extends HTMLElement {
// ...
connectedCallback() {
const shadow = this.attachShadow({ mode: "open" });
const incrementButton = document.createElement("button");
incrementButton.innerText = "+";
const decrementButton = document.createElement("button");
incrementButton.innerText = "-";
this.countSpan = document.createElement("span");
shadow.appendChild(incrementButton);
shadow.appendChild(decrementButton);
shadow.appendChild(countSpan);
incrementButton.addEventListener("click", this.increment);
decrementButton.addEventListener("click", this.decrement);
this.count = Number.parseInt(this.getAttribute("value") ?? "0");
}
// ...
}
And the markup:
<my-counter></my-counter>
I guess this looks cleaner.
Somehow I’m not sure that this is a right move in the right direction. It feels wrong expressing a DOM structure in an imperative way.
That’s why we have template! You can still express this structure in markup. template children won’t be actually rendered into DOM. It is only used for templating.
Now let’s start with the markup:
<template id="counter-template">
<button id="increment">+</button>
<button id="decrement">-</button>
<span id="count">0</span>
</template>
<my-counter></my-counter>
class Counter extends HTMLElement {
// ...
connectedCallback() {
const shadow = this.attachShadow({ mode: "open" });
const template = document.getElementById("counter-template");
const templateContent = template.content;
shadow.appendChild(templateContent.cloneNode(true));
shadow
.getElementById("#increment")
.addEventListener("click", this.increment);
shadow
.getElementById("#decrement")
.addEventListener("click", this.decrement);
this.countSpan = shadow.getElementById("#count");
this.count = Number.parseInt(this.getAttribute("value") ?? "0");
}
// ...
}
Since we finally left behind shadow DOM basics and templating, let’s look into slotting! The attributes are one way to change the behavior of a web component. But what if we want more flexibility and control for what’s being rendered?
For our my-counter web component, we can alter the rendered output with slots. Slots are capable of beaming up elements from the parent DOM tree. A child to a web component can be designated with a slot attribute and that that can be placed inside the shadow DOM. Magical, right? Using slots also circumvent the rules about styling. Slotted elements are styled based on the CSSOM of the tree they belong to. And the CSSOM of the shadow DOM does not apply to the slotted element.
How about a practical example? With slots, styling buttons in my-counter component is so much easier:
<template id="counter-template">
<slot name="increment-button"></slot>
<slot name="decrement-button"></slot>
<span id="count">0</span>
</template>
<my-counter>
<button slot="increment-button" style="background: light-green;">+</button>
<button slot="decrement-button" style="background: pink;">-</button>
</my-counter>
class Counter extends HTMLElement {
// ...
connectedCallback() {
// ...
shadow
.querySelector('slot[name="increment-button"]')
.addEventListener("click", this.increment);
shadow
.querySelector('slot[name="decrement-button"]')
.addEventListener("click", this.decrement);
//...
}
// ...
}
So far we’ve used a type of web components called ‘autonomous’. But there is another type which allows extending the existing types. That is called ‘customized built-in elements’.
So I can give superpowers to a, img, input?
Exactly, that's the idea. It's a way of building on top of the native web elements. As a practical example, let’s build an extended a element that helps us to track click events on links.
class LinkTracker extends HTMLAnchorElement {
constructor() {
super();
}
connectedCallback() {
this.addEventListener("click", this.handleClick);
}
handleClick = () => {
navigator.sendBeacon(/* */);
};
}
customElements.define("my-link-tracker", LinkTracker, { extends: "a" });
<a is="my-link-tracker" href="/some-path">Some Path</a>
Exciting, right? But here is the catch: Safari (WebKit) doesn’t support it. If you ask why, you can take a look at this very civilized discussion over here: https://github.com/WebKit/standards-positions/issues/97
I mean it’s not the end of the world, right? We can still use ‘autonomous’ ones.
But building a native-like input or a is the developer responsibility with the ‘autonomous’ option.
You can just wrap them inside the shadow DOM and call it a day?
There is a problem that we haven’t talked about so far. The shadow DOM is not rendered until the web component is hydrated(!). This means that the content will not be shown properly until the JS is parsed and executed.
You can use a slot though and opt out using a shadow DOM?
That’s the only viable alternative to the customized build-in elements. But why use two elements if only one could be suffice?
I guess one of the things that the developers despise over the years is building a customized select or checkbox. It’s not easy to achieve this without writing some amount of ‘ugly’ and ‘verbose’ code. To see whether web components can help with this, we will build a custom form item: my-number-input
For my-number-input to be considered as a form element, we need to use ElementInternals API. This API has many powers (which can be quite confusing). But it exposes some native element state properties that provide form input capabilities or improved accessibility features. You can also see this as a way to set up an interface for the browser internals.
class NumberInput extends HTMLElement {
static formAssociated = true; // This will make our web component a proper form element
constructor() {
super();
this.internals = this.attachInternals(); // initializing element internals
const shadow = this.attachShadow({ mode: "open" });
this.input = document.createElement("input");
this.input.type = "number";
this.input.value = "0";
shadow.appendChild(this.input);
this.input.addEventListener("input", () => {
const value = this.input.value;
const state = this.input.value;
// The state is a way to hold a representation of the actual value.
// For example, a value can be a string, but its state can be its base64 encoded version
// I know, it's confusing naming 🤷
this.internals.setFormValue(value, state);
});
}
// when the `form.reset` is called, this callback is invoked
formResetCallback() {
this.input.value = "0";
this.internals.setFormValue("0", "0");
}
}
customElements.define("my-number-input", NumberInput);
This example might seem quite trivial, but this can be a foundation to build a form element that has completely custom validation logic. For instance, we can use an attribute called min-value to have our own validation:
class NumberInput extends HTMLElement {
static observedAttributes = ["min-value"];
// ...
attributeChangedCallback(name, oldValue, newValue) {
// Updating validity when the min-value changes
if (name === "min-value" && oldValue !== newValue) {
this.minValue = Number.parseInt(this.getAttribute("min-value") ?? "0");
const currentValue = Number.parseInt(this.input.value ?? "0");
this.updateValidity(currentValue);
}
}
constructor() {
// ...
input.addEventListener("input", () => {
// ...
const currentValue = Number.parseInt(this.input.value ?? "0");
this.updateValidity(currentValue);
});
this.minValue = Number.parseInt(this.getAttribute("min-value") ?? "0");
}
updateValidity(newValue) {
if (newValue >= this.minValue) {
this.internals.setValidity({});
} else {
this.internals.setValidity(
{ tooSmall: true },
"value cannot be smaller than " + this.minValue,
this.input
);
this.internals.reportValidity();
}
}
// ...
}
// ...
As you can see, it still requires a lot of plumbing. Building a stable custom form element is still a difficult task. The reactivity does not come for free.
The element internals API also exposes accessibility object model. With that, it is possible to define inherent aria attributes for the component.
I thought I can just set aria-role attribute myself. Does that mean I need to use the element internals API for that?
Setting aria attributes manually is still possible and it achieves the same thing.
Previously we talked about how templating allows us to render declaratively. However, the shadow DOM requires JS to be parsed and executed, otherwise it won’t be rendered. Why would we wait for this to render the content? That’s why we need ‘declarative shadow DOM’, which is a spec that is recently adopted by the major browsers.
shadowrootmode attribute on template can be set to open or closed so that the browser can immediately instantiate the shadow DOM as soon as it parses the HTML. This prevents jank or FOUC (whatever term you want to use) and the content does not flash.
<my-counter value="1">
<template shadowrootmode="open">
<button id="increment">+</button>
<button id="decrement">-</button>
<span id="count">1</span>
</template>
</my-counter>
The shadow root will be automatically created and attached to the component instance when declarative shadow DOM is used.
At this point, I can say that the major capabilities of the web components have been covered. Now it’s time to take a look at why the web components are still not in mainstream after 10 year of its inception.
That’s an easy one to explain. The web component names are registered in a global namespace. So if you have two web components that relies on a different version of another web component library, get ready for a fun time. The moment you try to register another web component with a name that is already in element registry, an error will be thrown. You can either rename one in some way (maybe as a bundling stage) or you need to find a way to use only one version.
Love it or hate it, tailwind as a styling solution became mainstream and it showed that utility classes can be a solid abstraction for styling without dealing with CSS class naming and specificity issues. Unfortunately, when using shadow DOM there is no declarative way to use the document level styling.
Theoretically it is possible to generate scoped styling for each web component. But this means that the css will be duplicated in your JS bundle. And, yes, your styling will be shipped in your JS bundle and the size of your CSS (in JS) will still grow linearly.
Another workaorund is including your styles in the template. Unfortunately this means that for every template there should be the same style tag, which means the HTML size will be greater. This has even greater effect if declarative shadow DOM is used.
And one final workaround: Adopting a global stylesheet in the hydration phase. By doing so, you can apply any global stylesheet to a shadow DOM (https://developer.mozilla.org/en-US/docs/Web/API/ShadowRoot/adoptedStyleSheets). And you guessed it right, there is a catch: There will be FOUC since your components need to be hydrated to be rendered properly with adopted styles.
This is completely up to your project requirements, but it’s not very uncommon to render the content before it is served to the client. If your app is an SPA, you can skip this section. However, if you are rendering on the server, you need to find a way to serialize shadow DOM into a declarative template. As of today, there is no mature solution to render a web component on the server. Practically it is feasible to achieve that and there are some proof of concepts out there (e.g: https://github.com/lit/lit/tree/main/packages/labs/ssr#readme). The premise is that by using an emulated browser on the server, the generated shadow DOM can be streamed. The API’s that requires a client can be shimmed during this process. However there are so many issues that needs to be addressed to have a stable and reliable SSR solution.
The hydration order of the components are defined by various factors. But the rule of thumb is, once a component is registered, its lifecycles callbacks are invoked in the order of DOM (from top to bottom / depth first traversal). But this is only true for one type of web component. Imagine the following DOM structure:
<my-comp-1>
<my-comp-2> </my-comp-2>
</my-comp-1>
If the JS for my-comp-2 is parsed and executed before the JS for my-comp-1, my-comp-2 instance will be up and running before the my-comp-1 instance even though it is nested inside my-comp-2.
As you can imagine, if your business logic relies on a certain order, you have to deal with race conditions. Because depending on the network or the JS bundles or caching, the UI can be hydrated in a different order. Unless you wait for all web components to register, it is very hard to impose a top to bottom hydration. It is even harder to achieve bottom to top hydration like React does.
At the end of the day, if you are working with a team that is bigger than 5 people you might still need a framework. The web components won’t solve your UX/DX problems. A framework is an essential way to apply constraints on your solution space so that you won’t have footguns all over. A framework can provide you patterns for good UX. When to load your data (or when wait), how to split your bundles, how to deal with the state etc. Those problems won’t go away. You need to actively look out to solve these issues.
One thing I noticed during the time I spent with web components is that JSX is a great abstraction. Without that, the ability to type check or to compose component with high order components almost lost. And I believe that the web components can be more fun if they can be rendered both on the server and on the client in a composable way.
It is not unfair to say that the web component ecosystem is underwhelming. It can be argued that it hasn’t been marketed like other web frameworks do. There might be a chicken-egg situation here.
On the other hand, the web components have this promise: Write once, run anywhere. But why don’t we have a library or a marketplace of web components (as an example you can look here: https://www.webcomponents.org/) that is rich in content and actively maintained and used? Because reusable UI components are hard to build. The components need to be flexible and they should offer escape hatches to fit another use case. Even for React, it took a while to find effective patterns to build reusable components (headless components or lately just code generation - the ultimate hack for reuse). I’m quite skeptical that the web components can be used in such a way.
Another thing worth mentioning is that, many public web components for common use are published/bundled with their framework (e.g: lit, stencil). Even though these are lightweight frameworks (mostly >10kB), from an idealistic point of view this feels wrong.
The mainstream frameworks are already very mature and good at what they are doing. If you think that React is bloated and web components will help you ship less JS, you can consider using preact. Depending on your project, you might even notice that the amount of JS you ship is bounded by the number of components that are authored. So, in time the baseline cost that is incurred by the frameworks tend to disappear (e.g: bundle size comparison between svelte and react: github.com/halfnelson/svelte-it-will-scale).
If you think that you can write more efficient and fast code with web components, you can consider using svelte which uses a compiler to efficiently make DOM updates.
So before jumping on the web component train, you have to find your performance bottleneck and ensure that the web components will help you remedy your issues.
And here is some research to take a look, the shadow DOM is 3 times slower to parse than the light DOM (i.e the o.g. DOM): https://github.com/mfreed7/declarative-shadow-dom/blob/master/README.md#shadow-dom-vs-light-dom-speed
I hope this article can help you decide whether the web components can be a powerful tool for your next project. I tried to reflect my experience and my learnings. And with that I wanted to help you to have better decision.
From ease of use point of view, it can be helpful in a lot of ways. You can wrap your markdown content with a web component in your HTML and your web component can render the markdown for you. Or you can use a web component to render a map for the website of your store. No need to set up a whole complicated frontend pipeline. In addition, web components are an improvement for jquery style apps. If your UI logic is described imperatively, you can narrow the scope of your logic and encapsulate it within your component. And mixing with the custom event driven state management would take you far enough when implementing highly interactive applications. But for bigger projects, there are still rough edges. Be ready to find your solutions for them.