DEV Community

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

Posted on • Updated on

Web Fundamentals: Web Components Part 1

Contents

  1. Web components are here to stay
  2. What are custom elements?
  3. Let's build
  4. Component lifecycle
  5. 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:

  1. Custom elements
  2. Shadow DOM
  3. 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.

Two HTML counter components with a decrement and increment button along with a span showing the current count. The first snippet shows this component implemented with a wrapping  raw `div` endraw  and the second shows the same but with a custom HTML tag called  raw `custom-counter` endraw .

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

In this post, we'll be building a timer component and it's going to look like this:

An Excalidraw mockup of a timer component. The component is shown with an initial count of 0 and defined with HTML at the top and below is the same component after one second with the count reading 1. Between the two components is some orange text that reads "Increment count every second". The markup of the component is  raw `<article><h2>Timer</h2><div id="timer"><p>Count: <span>0</span></p></div></article>` endraw .

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:

An Excalidraw diagram of the markup of the  raw `x-timer` endraw  component. The markup reads:  raw `<article><h2>Timer</h2><x-timer><p>Count: <span>0</span></p></x-timer></article>` endraw .

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

Before we start coding it, let's establish the three ways you can define this internal markup:

  1. Define all of the markup in HTML, and use JavaScript to hook into x-timer's children elements
  2. Use JavaScript to create HTML elements and then add it to the inside of x-timer as children elements
  3. 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>
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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:

A screenshot of the timer component on the screen with a heading "Web components demo" with the browser console open on the right.

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

We'll get weird errors like this:

Uncaught DOMException: Operation is not supported
Enter fullscreen mode Exit fullscreen mode

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]:

  1. HTML parsing
  2. Calculating layouts and calculating painting details
  3. Compositing all of the individual elements together and finally draw them on the screen

An Excalidraw diagram visualizing how and when the browser recognizes and renders web components. When the browser encounters the web component in the parsing phase, it calls the constructor to instantiate it, and continues parsing the rest of the component. Only when it reaches the layout and painting phase does it invoke the lifecycle methods, and in this phase the components have access to DOM operations.

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]:

  1. connectedCallback
  2. disconnectedCallback
  3. attributeChangedCallback
  4. 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 -- */
Enter fullscreen mode Exit fullscreen mode

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.

The function signature of  raw `setInterval` endraw  expanded into words like this: "setInterval(any function we want to execute, delay in milliseconds);".

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.

The full signature of  raw `setInterval` endraw  showing that it returns an ID. It reads  raw `setInterval(callback, delay) => id` endraw .

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

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:

An Excalidraw mockup of the new component. It has the title "Dynamic timers" and two buttons below it that says "Add timer" and "Clear timers". Below it is the text "No timers running". On the right of the mockup, is the markup of the element, which reads  raw `<article><h2>Dynamic timers</h2><x-timers><button>Add timer</button><button>Clear timers</button><div id="timers" /></x-timers></article>` endraw .

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

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

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:

A screenshot of the final application. There are two main elements on the screen. The first one is a single timer and the second is the dynamic timer component with no timers currently running.

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.

A screenshot of the application running with the browser's console open to show the logging added to the web components.

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.

References

  1. MDN Web Components [Website]
  2. serve CLI tool [NPM]
  3. Using Custom Elements [MDN]
  4. Overview of RenderingNG architecture [Website]

Top comments (4)

Collapse
 
dannyengelman profile image
Danny Engelman

super() calls its parent class constructor which a class does by default if there is no constructor 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 return undefined 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:

Collapse
 
hasanhaja profile image
Hasan Ali

The connectedCallback runs on the opening tag; so its innerHTML is not defined yet. querySelector on this lightDOM will return undefined values.
Your code only works because it defines the component after DOM is created.

What do you mean by this? I think I may have misunderstood the lifecycle methods.

You want your components as self contained as possible

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.

bloated addEventListener()

Why do you think addEventListener is bloated? My understanding was that it's just a declarative alternative to overriding the onclick property. I couldn't find any documentation on this to suggest there might be a performance impact.

Collapse
 
dannyengelman profile image
Danny Engelman • Edited

connectedCallback

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.

<script>
  class MyBaseClass extends HTMLElement {
    connectedCallback() {
        console.log(this.innerHTML)
    }
  }
  customElements.define("my-component1", class extends MyBaseClass {})
</script>

<my-component1>FOO</my-component1>
<my-component2>BAR</my-component2>

<script>
  customElements.define("my-component2", class extends MyBaseClass {})
</script>
Enter fullscreen mode Exit fullscreen mode

Many developers, even libraries that make programming Web Components "easier", do

<script defer src="..."> to force all their components to load like component 2

They 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/write onclick. It is bloated because using onclick is shorter.
Use the ones that do the job for you: javascript.info/introduction-brows...

Thread Thread
 
hasanhaja profile image
Hasan Ali

Thank you! I'll check it out