Web Fundamentals: Web Components Part 2
Hasan Ali
Posted on February 8, 2024
Contents
- Recap
- More with Custom Elements
- React, not overreact
- Summary
1. Recap
So far, we’ve defined what web components are and caught a glimpse into what they can do. We've looked at how to define custom elements, how the browser sees them, and looked at what component lifecycle methods are. Well, some of them. What we're working towards in this series on web components is building up our understanding of what the web platform has to offer, so we can start to build a muscle for when it makes sense to reach for web components and what problems they're really well suited to solve. To get there, let's first dive back into where we left off in Part 1.
2. More with Custom Elements
Our x-timer
component so far was capable of connecting to the document and cleaning up any resources when it disconnected. However, we have no way of customizing it without delving into source markup. This was fine for that example, but this would be very limiting if this was a component you created to be downloaded and used somewhere else. We've already seen how other HTML elements like div
and button
take on additional data in the form of attributes, and we can do the same thing with custom elements. What we want to achieve is let the consumer of x-timer
set any interval for their timer:
<x-timer x-interval="3"></x-timer>
This doesn't do anything yet but this will be the API we expose. Let's start wiring it up:
// timer.js
class Timer extends HTMLElement {
static attrs = {
interval: "x-interval",
}
// --snip--
}
This doesn't do anything yet either, but I've found this to be a simple but useful approach to define element attributes in one place [1]. The attrs
object acts as a map that holds the key we'd like to reference the HTML attribute by. I found this pattern useful because conventionally HTML attributes are hyphenated if more than one word (also known as kebab-case), and I found it easier to work with camelcase object properties because you can rely on tooling like autocomplete in your editor. To actually wire it up:
class Timer extends HTMLElement {
static attrs = {
interval: "x-interval",
};
/**
* @type { number }
*/
#count;
/**
* @type { HTMLElement }
*/
#countSpan;
/**
* @type { number }
*/
#timerId;
constructor() {
super();
}
connectedCallback() {
console.log("x-timer connected");
this.#count = 0;
this.#countSpan = document.createElement("span");
const interval = parseInt(this.getAttribute(Timer.attrs.interval) ?? "1");
this.#timerId = setInterval(() => {
console.log("Timer called");
this.#count++;
this.#countSpan.textContent = this.#count.toString();
}, interval * 1000);
const countParagraph = document.createElement("p");
countParagraph.textContent = "Count: ";
this.#countSpan.textContent = this.#count.toString();
countParagraph.appendChild(this.#countSpan);
this.appendChild(countParagraph);
}
disconnectedCallback() {
console.log("x-timer disconnected");
clearInterval(this.#timerId);
}
}
Aside: the code is no longer in TypeScript, but typed with JSDoc. I've done this refactor so you can actually copy and paste the code, and run it directly without a build step. As an aside, I've been enjoying this approach to both authoring web components and JavaScript in general.
A few things to note here with the base code. The timer declaration has moved from the constructor
to the connectedCallback
. This was both a correction and a way to prevent awkward side effects from occurring, like if the component was disconnected and connected back into the document the timer wouldn't restart because the constructor wouldn't be called.
const timer = document.querySelector("x-timer"); // constructor called
document.body.appendChild(timer); // connectedCallback called
timer.remove() //disconnectedCallback called
document.body.appendChild(timer); // connectedCallback called
Most of that should look identical to Part 1. The way you handle attributes passed into a custom element is by just reading it using this.getAttribute
, and since getAttribute
could return null
, we set a default value too before parsing it into an integer.
Note: The code you see above could very easily have been
this.getAttribute("x-interval")
instead of using the static attribute map pattern that we've done.
Moreover, the query-ability of a component depends on two thing: the loading of the script and the parsing of the HTML [2]. If the HTML was parsed before the script was loaded, then when the script loads and the custom element is defined, the constructor will have access to the attributes and the children nodes [3]. However, if the script and the custom element definition loaded first, then the constructor would not be able to access the document nodes because it might not be fully parsed yet. To simplify this for our example, we can move this query logic to the connectedCallback
, which will only run when the element is connected to the document.
By querying the attribute in the connectedCallback
, we'd will only set the interval with the attribute once for when the component connects to the document. That means the timer wouldn't update if you change the attribute from the outside like so:
const timer = document.querySelector("x-timer");
timer.setAttribute("x-interval", "10");
To do that, we'll need to tell the component to observe changes to the attribute and use another lifecycle method called attributeChangedCallback
to manage the changes. To observe attributes, you can declare a static property called observedAttributes
that the platform recognizes and list the names of the attributes to be tracked. That would change our code like so:
class Timer extends HTMLElement {
static attrs = {
interval: "x-interval",
};
static observedAttributes = [Timer.attrs.interval];
// --snip--
}
Note: This is equivalent
static observedAttributes = ["x-interval"];
, and if you had more than one attribute to track, you could either comma-separate them in that array individually, or useObject.values
like thisstatic observedAttributes = Object.values(attrs);
[3].
Another equally valid approach to define observedAttributes
is to make it a static getter method:
class Timer extends HTMLElement {
static attrs = {
interval: "x-interval",
};
static get observedAttributes() {
return [Timer.attrs.interval];
}
// --snip--
}
As far as I can tell, they work exactly the same way.
3. React, not overreact
Now that we've told the custom element to track the changes to our attributes, we're ready to handle them in the attributeChangedCallback
. This is the signature of the method [4]:
/**
* @param { string } name
* @param { string } oldValue
* @param { string } newValue
*/
attributeChangedCallback(name, oldValue, newValue) {}
This callback gets called when an attribute changes. It is the same callback that gets called for every attribute change. If you have many attributes changing all at once, this callback would get called for each attribute change, respectively, for the number of times they've changed. This means that you will need to ensure in the callback that the updates you make only pertain to the attribute responsible for it, and that there's an actual change in value; you can set the attribute with the same value multiple times, and this would trigger the callback for each change. The way you know what attribute triggered the callback is by checking the name
parameter, and to check if the value has changed, you can compare the oldValue
with the newValue
:
class Timer extends HTMLElement {
static attrs = {
interval: "x-interval",
};
static observedAttributes = [Timer.attrs.interval];
// --snip--
attributeChangedCallback(name, oldValue, newValue) {}
}
Before we flesh this logic out, let's take a step back and think about the order of events from the browser's perspective. First, the browser instantiates a custom element after encountering it in the parsing phase. Then it calls a few lifecycle methods in the layout and painting phase, before finally connecting the component to the screen in the composite phase. A quick shortcut I use to remember this flow is by thinking about how I would create an HTML element in JavaScript, configure it and then insert it into the document [5]. The steps I'd follow are:
const newTimer = document.createElement("x-timer"); // constructor called
newTimer.setAttribute("x-interval", "2"); // attributeChangedCallback called
document.body.appendChild(newTimer); // connectedCallback called
If you squint a bit, this is essentially what the browser does when you declaratively use custom elements in HTML. The attributeChangedCallback
gets called too because the setting of the initial value is also considered a change. This makes sense from the signature because the oldValue
would've been undefined
and the newValue
is the value you've just set.
With that context, you'll need to reason about the order of events when checking or manipulating member properties from within the different methods. For example, this.#timerId
gets set in the connectedCallback
and since that gets called after attributeChangedCallback
, we'll need to ensure we check if the element is connected before creating a new timer (or conversely do nothing if the element is disconnected) [3]. If we wanted to change the timer interval after putting the element on the document, then we would need to ensure our update logic doesn't accidentally run before the update. To do this, we can simply check if the element is connected using the isConnected
property that's available to every document node, and return early from the callback if not.
class Timer extends HTMLElement {
// --snip--
attributeChangedCallback(name, oldValue, newValue) {
if (!this.isConnected) {
return;
}
}
}
We also only want to do this if the oldValue
and the newValue
for any tracked attribute is different, so we can wrap our conditional with that first:
class Timer extends HTMLElement {
// --snip--
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) {
if (!this.isConnected) { /* --snip-- */ }
}
}
}
Checking for the value difference first before the attribute name means that we will only look to do work for an attribute when we know that there has been a change at all. Since we only want our timer update logic to depend on the interval attribute, we can put all of the logic related to it in another conditional that compares the interval attribute name with the name
input parameter in the callback:
class Timer extends HTMLElement {
// --snip--
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) {
if (name === Timer.attrs.interval) {
if (!this.isConnected) { /* --snip-- */ }
}
}
}
}
Now we're ready to add the logic to replace our existing timer with a new timer when the interval attribute is updated:
class Timer extends HTMLElement {
// --snip--
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) {
if (name === Timer.attrs.interval) {
if (!this.isConnected) { /* --snip-- */ }
const parsed = parseInt(newValue);
const interval = isNaN(parsed) ? 1 : parsed;
clearInterval(this.#timerId);
this.#timerId = setInterval(() => {
console.log("New timer called", interval);
this.#count++;
this.#countSpan.textContent = this.#count.toString();
}, interval * 1000);
}
}
}
}
This logic should look very similar to what we previously saw in the connectedCallback
. Note that we performed some clean up using clearInterval
to manage our resources before reassigning it. We do this because, the reassignment of this.#timerId
only mutates the ID that it has stored, and doesn't delete the timer from the scope it lives in. If we don't perform this cleanup, then the previous timer would continue to run in the background and continue to update the counter on its interval alongside the new one, which as you'd imagine would be very confusing.
This is what the final HTML looks like with the logic that updates the timer, which you'd also be able to find on my GitHub.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>Web Components Part 2</title>
<link href="styles.css" rel="stylesheet">
<script src="timer.js"></script>
</head>
<body>
<h1>Web components demo</h1>
<main>
<x-timer></x-timer>
<x-timer x-interval="5"></x-timer>
<x-timer x-interval="2"></x-timer>
<button>Update timer 2 to 10 seconds</button>
</main>
<script>
const timerTwo = document.querySelector("x-timer[x-interval='2']");
const button = document.querySelector("x-timer[x-interval='2'] + button");
button.addEventListener("click", () => {
timerTwo.setAttribute("x-interval", "10");
});
</script>
</body>
</html>
Summary
This covers all but one of the lifecycle methods that you get access to with custom elements, and in actuality, these are what you'd use most of the time. The last one is adoptedCallback
and you'd most likely encounter it in the context of <iframe>
elements [6]. Even though I don't have plans to go through it in the series, the concepts and the way we've covered them so far should give you a good idea on how to begin unpicking it if you do. Other than that, we have enough under our belt to delve even deeper into what web components have to offer.
All of this might feel like a lot of work to put some HTML on the screen and update a few attributes, and it is. Even though we've achieved fine-grained updates in our component, it took a fair bit of reasoning about the lifecycle methods to get there. The reason the custom elements API feels a little cumbersome is because it's low level by design to give you the most control. This means that you can do anything with it, including build your own abstractions on top of it.
There are other component-based abstractions that will give you the same effect with a lot less work, and some even give you the same level of fine-grained control. Though we'll look at a few UI frameworks alongside web components, the ultimate aim of this series is to demonstrate what the web platform is capable of, and get you to start thinking about the different tradeoffs you make when picking different tools. My hypothesis is that by understanding what the platform has to offer, you'll be in a much better position to evaluate the complexity you choose to take on when building experiences. Lastly, I'll leave you with a little spoiler of what's to come: web components are cool and they are here to stay.
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.
References
Posted on February 8, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.