DEV Community

Cover image for Web Fundamentals: Web Components Part 2
Hasan Ali
Hasan Ali

Posted on

Web Fundamentals: Web Components Part 2

Contents

  1. Recap
  2. More with Custom Elements
  3. React, not overreact
  4. 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>
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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 use Object.values like this static 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--
}
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

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

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.

Spider-Man pointing meme where Spider-Man points to another Spider-Man (source: https://www.cbr.com/best-spiderman-pointing-memes/)

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

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

  1. Zach Leatherman's <browser-window> component [GitHub]
  2. Danny Engelman's comment on loading order [Comment]
  3. 𒎏Wii's comment on loading order [Comment]
  4. MDN Web Components [Website]
  5. Component Lifecycle Reference Diagram [Website]
  6. Component Lifecycle Reference [Website]

Top comments (12)

Collapse
 
dannyengelman profile image
Danny Engelman

What is the point of attrs ??

 static attrs = {
    interval: "x-interval",
  };
  static get observedAttributes = [Timer.attrs.interval];  
Enter fullscreen mode Exit fullscreen mode

over

  static get observedAttributes = ["x-interval"];  
Enter fullscreen mode Exit fullscreen mode

When this.constructor.observedAttributes always gets you the attributes Array (if required at all)

Collapse
 
hasanhaja profile image
Hasan Ali

It just acts as a map, and it's not required at all.

I saw that pattern when looking through other web components in the wild, and thought it was a nice way to organize attributes in one place, and maybe even alias them like we've done there.

Collapse
 
dannyengelman profile image
Danny Engelman

So the next question is: What is the point of aliasing?

Thread Thread
 
hasanhaja profile image
Hasan Ali

Convenience, really. If you wanted to decouple the API from the implementation, this makes it a little more convenient to change things in the future. For example, if I wanted to rename the attribute, I can do it in the attrs object and not have to refactor the rest of the component where I've addressed it.

Do you think there's a better way to do this? The simple alternative I can think of is use the attribute string directly, and then do a find-and-replace if things change.

Thread Thread
 
dannyengelman profile image
Danny Engelman • Edited

Good point "future changes"

You will have a cleaner and smaller class when you declare all that sh* outside the class (at the top of your file)

const interval = "x-interval";
const my_component_attrs = [ interval ];

customElements.define( "my-component" , class extends HTMLElement {
  static get observedAttributes(){
   return my_component_attrs;
  }
...
Enter fullscreen mode Exit fullscreen mode

Note #1 All good minifiers will replace references with the const value, so you get smaller code as well

Note #2 my-component is often parameterized (is that a word?) as static tagName on the class in recent examples as well.
WHY?!? It is available on the component instance as this.localName or this.nodeName

Collapse
 
darkwiiplayer profile image
𒎏Wii 🏳️‍⚧️

You might have noticed that the timer declaration has moved from the constructor to the connectedCallback, and this was to simplify passing in the dynamic interval delay value, which would only be queryable once the element is mounted (or connected)

There's two things I want to add here:

  1. Setting up the timer in the constructor and removing it in the connectedCallback would have meant the component would have stopped working if it was ever removed from the document and inserted elsewhere. Setting it in the connectedCallback to re-start it when re-attaching is the correct way about this.

  2. It is not entirely correct that the attribute for the delay value is only queryable when the element is mounted. If the element has already been parsed by the time the custom element is defined, the constructor will have full access to the element and its children, including attributes. Only if the custom element is already defined before the element is parsed, the constructor will run before any of the attributes or child elements have been parsed.

Note: This is equivalent static observedAttributes = ["x-interval"];, and if you had more than one attribute to track, you would comma-separate them in that array.

If you already have an attrs attribuet, then I would hope nobody would actually comma-separate them and instead just write something like this:

static observedAttributes = Object.keys(Timer.attrs)
Enter fullscreen mode Exit fullscreen mode

Last but not least, the final version of the component would behave somewhat inconsistently. Detaching the component would initially pause the counting. Updating the interval attribute would then resume the counting. Connecting the element again would then start a second interval that could never be cancelled anymore and would continue to count up until the page is closed.

An easy way to fix this would be to just check if the object is actually connected in the attributeChangedCallback 😁

Collapse
 
hasanhaja profile image
Hasan Ali

Thank you very much for your feedback.

Setting it in the connectedCallback to re-start it when re-attaching is the correct way about this.

This makes so much sense, and I don't know how I missed this!

It is not entirely correct that the attribute for the delay value is only queryable when the element is mounted. If the element has already been parsed by the time the custom element is defined, the constructor will have full access to the element and its children, including attributes. Only if the custom element is already defined before the element is parsed, the constructor will run before any of the attributes or child elements have been parsed.

This has taken me a quite a while to wrap my head around. I had someone else also flag this for me when I did the first post, and to be honest it hasn't clicked until now. I'll work on the correction and update the post. Thank you!

static observedAttributes = Object.keys(Timer.attrs)

Good shout! I'll add that in too and signpost that by doing so all of your attributes will be tracked, which is probably what you'd want most of the time.

Detaching the component would initially pause the counting. Updating the interval attribute would then resume the counting. Connecting the element again would then start a second interval that could never be cancelled anymore and would continue to count up until the page is closed.

Great catch! I definitely didn't play that scenario out. I'll make the correction!

Honestly, thank you so much for taking the time to read the post and give me feedback! Greatly appreciate it.

Collapse
 
alexroor4 profile image
Alex Roor

This is definitely a great article! I was delighted with the first part, and then there’s the second! thank you

Collapse
 
alexroor4 profile image
Alex Roor

Your article is a fantastic resource for anyone looking to delve deeper into this subject. I'll definitely be sharing it with my colleagues.

Collapse
 
hasanhaja profile image
Hasan Ali

Thank you for your kind words!

Collapse
 
darkwiiplayer profile image
𒎏Wii 🏳️‍⚧️

One pattern I end up repeating for pretty much every slightly larger component I write is this:

attributeChangedCallback(attribute, from, to) {
   // snake-case to camelCase
   const name = attribute.replaceAll(/-[a-z]/g, str => str.slice(1).toUpperCase()) + "Changed"
   if (name in this) this[name](from, to)
}
Enter fullscreen mode Exit fullscreen mode

Then I can just add methods like xIntervalChanged(from, to) { /* ... */ } instead of having a long if in a single method.

Collapse
 
dannyengelman profile image
Danny Engelman • Edited

Same for Events:

.addEventListener("click",this)
Enter fullscreen mode Exit fullscreen mode
handleEvent(evt) { // standard method on every Object
  let method = "event_" + evt.type;
  this[method] && this[method](evt)
}
Enter fullscreen mode Exit fullscreen mode
event_click(evt) { }
Enter fullscreen mode Exit fullscreen mode