Contents
- Web components are here to stay
- What are custom elements?
- Let's build
- Component lifecycle
- Summary
1. Web components are here to stay
Web components are an abstraction to build and organize UI code. It's a set of browser native tools you can use to encapsulate HTML markup, CSS styles and JavaScript behavior into reusable components [1]. The component model is a useful abstraction when dealing with complex states and non-trivial integrations, and there are many frameworks in this space offering their unique proposition. Web components is one such solution but it's a web standard. The abstraction mainly serves us, the authors of the code, so that we can effectively manage and maintain it in the future.
The tools the browser provides for this are:
- Custom elements
- Shadow DOM
- HTML templates
In this post, we'll be taking a closer look at what custom elements are, how to create them and how they work under the hood.
2. What are custom elements?
Custom elements enables you to define your own HTML tags, and encapsulate markup and behavior within it. For example, if you're building a counter component, you can use a custom element to keep track of the count and the click listeners for the buttons within it and also give it a meaningful name. By naming it in the tag, as opposed to giving an existing HTML element like a div
an ID, you define one place where logic related to that component should live. It'll roughly behave the same way, but it has better semantics at the HTML level.
The first approach is perfectly valid and for a simple component like a counter, this would suffice. It also requires fewer lines of JavaScript than the web component counterpart, however, when the complexity of the component grows, the additional lines of JavaScript are warranted. The grouping of related code together also reduces a part of the maintenance burden, which is to hunt down all the different parts of your codebase that could be managing this component's behavior.
What tools do we need to build custom elements? Since web components are native to the web platform, you don't need any additional dependencies to get you going. There are frameworks that help remove some boilerplate but, in essence, the workflow to build your own custom element is to create a class that extends HTMLElement
and pass that as a parameter to a built-in API customElements.define
with a tag name. Easy!
2. Let's build
The best way to learn is by doing, so let's start building and explore any theory along the way. To follow along, you'll only need a text editor and a browser. You might notice from the code snippets or the source code for this post that I'm using Astro and TypeScript but you can just as easily follow along with plain HTML and JavaScript. When doing so, open your HTML file directly in your browser to see your page in action, or if you really want to serve it from a static file server, I recommend the CLI tool serve
[2]. Using that tool is as simple as:
serve <your-file>.html
In this post, we'll be building a timer component and it's going to look like this:
As soon as the page loads, the counter is going to start incrementing every second. However, instead of a div
with a "timer" ID, we're going to make our own web component called "x-timer" (I wanted to call it "timer" but one of the rules of custom elements is that it needs to be at least two words separated by a hyphen, so you can distinguish them from built-in HTML elements [3] and also naming things is hard). That will make the final markup look like this:
We want the component to have the following internal markup so we can update just the span
with the incrementing count values:
<p>
Count: <span>0</span>
</p>
Before we start coding it, let's establish the three ways you can define this internal markup:
- Define all of the markup in HTML, and use JavaScript to hook into
x-timer
's children elements - Use JavaScript to create HTML elements and then add it to the inside of
x-timer
as children elements - A combination of the first two approaches
For our x-timer
component, we're going to define the web component itself in the markup and then use JavaScript to define its internals:
<article>
<h2>A single timer</h2>
<x-timer />
</article>
Correction: This is incorrect HTML and can lead to unexpected results. The correct way to denote an empty HTML element is by using both the opening and closing tags:
<x-timer></x-timer>
. For more context, see Web Fundamentals: The HTML-JSX Confusion.
In our JavaScript, we can define the component such that when it's loaded into the document, it'll create the internal HTML elements and expand into this:
<article>
<h2>A single timer</h2>
<x-timer>
<p>Count: <span>0</span></p>
</x-timer>
</article>
Awesome! This gives us some scaffolding and a plan of action.
We'll need the following structure for any web component we want to define:
class Timer extends HTMLElement {
constructor() {
super();
}
}
customElements.define("x-timer", Timer);
We've named our class Timer
and that extends an HTMLElement
. We want our custom tag to be called x-timer
and for the browser to recognize it, we need to define it using customElements.define
and give it a class so it knows what to do for that tag. Now, let's make it dance:
class Timer extends HTMLElement {
count: number;
constructor() {
super();
this.count = 0;
setInterval(() => {
console.log("Timer called");
this.count++;
}, 1000);
}
}
/* --snip -- */
We've added a property to the class to keep track of the count state, and initialized it with 0 when it's constructed. We also defined a timer using the JavaScript function setInterval
and passed in a delay of 1000 milliseconds, along with a callback that both logs to the console every time the timer is called and increments the count
property of the class by 1. This is the entire mechanism of the component that will drive timer, but we still need to put this count on the screen.
class Timer extends HTMLElement {
/* --snip -- */
constructor() {
/* --snip -- */
const countSpan = document.createElement("span");
const countParagraph = document.createElement("p");
countParagraph.textContent = "Count: ";
countSpan.textContent = this.count.toString();
countParagraph.appendChild(countSpan);
this.appendChild(countParagraph);
setInterval(() => {
/* --snip -- */
countSpan.textContent = this.count.toString();
}, 1000);
}
}
/* --snip -- */
That should do it! We've defined a span
and a p
within the constructor and then set their initial text content. We've then appended the span into the paragraph, and finally the paragraph into the parent (our x-timer
component), to create the final structure of our component. In the setInterval
callback, we've added a line to update the span's text content whenever count changes. When you view this in your browser, you should see counter incrementing every second like we set out to do. Adding styles is optional, but you should now have a page that roughly resembles this:
We're not quite done because I've misled you a bit and shown you code that can break unexpectedly. While this happens to work when we've explicitly used this custom element in our HTML, it'll break in weird ways when you create it dynamically like this and try to insert it into the document:
const timer = document.createElement("x-timer");
document.body.appendChild(timer);
We'll get weird errors like this:
Uncaught DOMException: Operation is not supported
To understand what is going on here, we need to get introduced to the lifecycle of components.
3. Component lifecycle
How does the browser render a web component? How does it update when attributes change or the component gets removed from the document? So far in our implementation, we've assumed the browser recognizing a web component and rendering it onto the screen to be the same event. However, they are distinct events in the browser's rendering pipeline and this has an impact on how we define and manage the components.
The browser's rendering pipeline can be generalized into three phases [4]:
- HTML parsing
- Calculating layouts and calculating painting details
- Compositing all of the individual elements together and finally draw them on the screen
When the browser encounters a web component, it instantiates it by calling the constructor and continues parsing the rest of the document. Since it's not yet being rendered, it won't have access to the document. This is why our components behavior is flaky depending on if we use it directly or create it programmatically. The way to properly build our component would be to hook into what are called "lifecycle methods". These are methods on the component that get invoked later in the rendering pipeline when DOM operations are available. These methods are [3]:
connectedCallback
disconnectedCallback
attributeChangedCallback
adoptedCallback
These callbacks are invoked in the Layout and Painting phase. Let's refactor our implementation to make use of the connectedCallback
:
class Timer extends HTMLElement {
/* --snip -- */
countSpan: HTMLElement;
constructor() {
/* --snip -- */
this.countSpan = document.createElement("span");
setInterval(() => {
/* --snip -- */
this.countSpan.textContent = this.count.toString();
}, 1000);
}
connectedCallback() {
console.log("x-timer connected");
const countParagraph = document.createElement("p");
countParagraph.textContent = "Count: ";
this.countSpan.textContent = this.count.toString();
countParagraph.appendChild(this.countSpan);
this.appendChild(countParagraph);
}
}
/* --snip -- */
We've moved all the logic related to appending elements to the web component to the connectedCallback
method. We've also had to make countSpan
a property of the component so that we can reference it outside the constructor's scope. We can happily initialize it in the constructor because we're just creating an element and not using it in any DOM operation until it's connected to the document. If you refresh your browser with those changes, you'll see it still works the same but it's been implemented correctly so that it can even be created programmatically without any hiccups.
We're not quite in the clear though. What happens when our component is removed from the document? By looking at the names of our lifecycle methods, it appears that the disconnectedCallback
method will get invoked, but do we need to do anything with it? In our implementation, our component will only get disconnected when the entire page is disconnected, either through navigation or page close. In that case, we don't need to worry about manually disconnecting or deleting anything, and we can rely on the browser to perform the cleanup actions. We only need to worry about it if we remove web components dynamically, but that shouldn't be something we stipulate in our component design. We should build our components so that it can be used just like any other HTML element. So, what part of our component requires manual cleanup? The dynamically created paragraphs and spans will get removed automatically when the parent component gets removed. And so will the component's properties. The browser's garbage collector will handle all of that like it does for any JavaScript object. What about our timer?
setInterval
works by taking in a callback function and a delay. It then executes that callback repeatedly with the delay we defined between each execution. However, where does this interval live in memory? Is it scoped to the context it was created? So in our case, would that be the constructor or the class? The short answer is that it isn't scoped to the context it was created in and has a separate execution context altogether.
I like to think of the interval as living in some global context and the setInterval
function gives us a way to push things onto it (for a deeper look into how it works, see this article on the JavaScript Event Loop). This means that the interval we created will outlive the component by default because the global context will outlive the component. This is precisely why the full signature of setInterval
also returns an ID that we can use to delete the interval using the method clearInterval
.
If we don't delete the interval, it will continue to execute in the background and can potentially lead to memory leaks. The browser's garbage collection will kick in when you navigate to a different page or close the tab, but if you're intention is to build a long-lived application then this will accumulate indefinitely and bring the application to a grinding halt. We need to refactor our code slightly to account for the cleanup of intervals:
class Timer extends HTMLElement {
/* --snip -- */
timerId: number;
constructor() {
/* --snip -- */
this.timerId = setInterval(/* --snip -- */);
}
/* --snip -- */
disconnectedCallback() {
console.log("x-timer disconnected");
clearInterval(this.timerId);
}
}
/* --snip -- */
We've assigned the timerId
returned by setInterval
to a property so we can reference it outside of the constructor’s scope, and we clear the interval in the disconnectedCallback
. If we re-run our page, everything should look... the same. Well, that's a little anti-climatic.
Let's create a completely new component whose sole responsibility it is to add new timers and clear all of them when we're done. We can use this new component to tinker with x-timer
:
To changes things up a little, let's define most of the internals of the component using HTML and so we can see what it looks like to use JavaScript to hook into children elements. The only element we'll be adding dynamically is x-timer
, so I've added an empty div
to be a container for all of them. That would make our HTML look like this:
<article>
<h2>Dynamic timers</h2>
<x-timers>
<button aria-label="add-timer">Add timer</button>
<button aria-label="clear-timers">Clear timers</button>
<div aria-label="timers">
</div>
</x-timers>
</article>
Note: I've made use of
aria-label
to demonstrate different ways of selecting an element in the document, but you can select them however you'd like.
Then the corresponding component definition would look like this:
class Timers extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
console.log("x-timers connected");
const timersDiv = this.querySelector("[aria-label='timers']");
const addTimerButton = this.querySelector("[aria-label='add-timer']");
const clearTimersButton = this.querySelector("[aria-label='clear-timers']");
// Default state
timersDiv.textContent = "No timers running";
clearTimersButton.addEventListener("click", () => {
// Return to default state
timersDiv.textContent = "No timers running";
});
addTimerButton.addEventListener("click", () => {
// Clear timers div if no timers present
if (timersDiv.textContent === "No timers running") {
timersDiv.textContent = null;
}
const xTimer = document.createElement("x-timer");
timersDiv.prepend(xTimer);
});
}
}
customElements.define("x-timers", Timers);
Since there was no setup required for this component, all of the logic lives in the connectedCallback
. You should now have a page that roughly resembles this:
You can create as many timers as you'd wish and clear them and since we've added the appropriate cleanup logic to x-timer
everything will work correctly. Now, you can experiment with what happens when you remove clearInterval
from disconnectedCallback
in x-timer
. Since we've added some helpful logging statements, you should be able to see when the different methods get called.
Summary
Custom elements are a key part of the web component's toolchain. They enable you to define your own HTML tag and also let you define the inner workings of your component. By looking at how the browser parses and renders HTML, we saw why we need component lifecycle methods and when they get invoked. We leveraged it to ensure we cleaned up after ourselves so we don't create a memory leak in our application. All of this sets the stage for us to explore just how powerful and capable the web component toolchain is.
In the next part, we'll build on the lifecycle methods and in Part 3, we'll explore the Shadow DOM and HTML Templates. We'll also explore web components’ place in the web UI ecosystem alongside giants like React, Angular and Vue. We’ll approach it as objectively as possible to understand the real, non-negligible tradeoffs you make with each approach. The aim is to give you the tools and context so you can make the right calls for your problems.
All the source code for this post can also be found on my GitHub. You might even get a little sneak peek at the demos and comparisons I’m putting together for Part 2 👀
If you think of anything I've missed or just wanted to get in touch, you can reach me through a comment, via Mastodon, via Threads, via Twitter or through LinkedIn.
Top comments (4)
super()
calls its parent classconstructor
which a class does by default if there is noconstructor
defined; so you can leave out those 3 lines.The
connectedCallback
runs on the opening tag; so its innerHTML is not defined yet.querySelector
on this lightDOM will returnundefined
values.Your code only works because it defines the component after DOM is created.
You want your components as self contained as possible; if you want users to include their own (inner)HTML; shadowDOM with
<slot>
is the best approach.It is your own component; No one else (most likely) is going to append Eventlisteners inside your component; thus bloated
addEventListener()
is not required.Refactored
<x-timers>
can be:What do you mean by this? I think I may have misunderstood the lifecycle methods.
I went this approach to showcase different ways to author the component (creating elements with JavaScript and progressively enhancing with JavaScript). In this context there isn't too much benefit with the progressive enhancement approach, so your refactored version works great.
Good shout on the shadom DOM approach too. That's what I'm working on now for the follow up post.
Why do you think
addEventListener
is bloated? My understanding was that it's just a declarative alternative to overriding theonclick
property. I couldn't find any documentation on this to suggest there might be a performance impact.connectedCallback
Update: see long read Dev.to post: Developers do not connect with the connectedCallback (yet)
Experience yourself; FOO is NOT logged to the console, BAR is logged
Because
connectedCallback()
is executed on the OPENING tag (and when DOM nodes are moved around!)So for component1
this.innerHTML
is referencing DOM that does not exist/isn't parsed yet.Many developers, even libraries that make programming Web Components "easier", do
<script defer src="...">
to force all their components to load like component 2They don't understand the behavior, thus use
defer
. They probably use!important
as well when they do not understand CSS Specificity.LifeCycle Methods:
andyogo.github.io/custom-element-r...
Event listeners
addEventListener
doesn't override/writeonclick
. It is bloated because usingonclick
is shorter.Use the ones that do the job for you: javascript.info/introduction-brows...
Thank you! I'll check it out