Web Components Custom Elements Lifecycle is What Makes Them Useful

November 19, 2023 📬 Get My Weekly Newsletter

Feedback from my previous post on Web Components was that the lifecycle callback methods like connectedCallback are actually what makes custom elements useful. After exploring this more, I can see why and want to demonstrate.

The examples in my previous post demonstrated progressive enhancement for a server-rendered page. I anchored to this as that was the crux of Jim Neilsen’s blog post, and a lot of discussion around Web Components is how they can support progressive enhancement. In that scenario, the callbacks on a custom element don’t seem useful.

But, as was pointed out to me a few times on Mastodon and email, these callbacks vastly simplify managing dynamic insertion of custom elements. If you want to insert (or remove) a component, the callbacks provided by the custom elements API trigger automatically.

If you are just using the DOM APIs—my so-called “vanilla” implementation—you have to do a lot of heavy lifting yourself. Let’s see all this in action.

Manual Lifecycle Management

Let’s enhnace the user avatar example and create a button that, when clicked, inserts a new user avatar component into the DOM, without re-rendering the page or performing any server-side interaction.

<div data-tooltip>
  <img
    src="https://naildrivin5.com/images/DavidCopelandAvatar-512.jpeg"
    alt="Profile photo of Dave Copeland"
    width="64"
    height="64"
    title="Dave Copeland"
    />
</div>
<hr>
<button data-add-new>Add New Component</button>
<h2>New Components are added here</h2>
<section></section>

To focus on the behavior, I’m not going to extract the markup into a template—there will be some duplication but set that aside for a moment. Here is the JavaScript for the button press:

document.querySelectorAll("[data-add-new]").forEach( (e) => {
  e.addEventListener("click", (event) => {
    event.preventDefault()
    const section = document.querySelector("section")
    section.insertAdjacentHTML("beforebegin",`
<div data-tooltip>
  <img src="https://naildrivin5.com/images/DavidCopeland-old.jpeg"
       alt="Old Profile photo of Dave Copeland"
       width="64"
       title="Younger Dave Copeland"
       />
</div>`)
  })
})

If you run this (see CodePen version), you’ll notice that while the <img> tag is inserted, the tooltip is not added. This is because there is nothing to trigger the code that does the enhancement. That code already ran.

The Complexity of Doing it Yourself

To make this work, we need to create our own abstraction so that we can create a new component that we then enhance. There are a ton of ways to do this, but here is one that introduces the fewest new concepts.

First, we create a class that wraps the element and exposes an enhance method that does the progressive enhancement:

class UserAvatar {
  constructor(element) {
    this.element = element
    const $img = element.querySelector("img")
    this.src = $img.getAttribute("src");
    this.name = $img.getAttribute("title");
  }

  enhance() {
    this.element.insertAdjacentHTML(
      'beforeend', 
      `<div>tooltip ${this.name}</div>`
    );
  }
}

Now, our initialization code will create an instance of this class and call enhance:

document.querySelectorAll("[data-tooltip]:has(img[src][title])").
  forEach( (element) => {
    const userAvatar = new UserAvatar(element)
    userAvatar.enhance()
  })

Here’s where it gets really nasty. Because we ultimately need an Element, we have to create one using DOM methods and not strings:

document.querySelectorAll("[data-add-new]").forEach( (e) => {
  e.addEventListener("click", (event) => {
    event.preventDefault()
    const section = document.querySelector("section")
    const element = document.createElement("div")
    const img = document.createElement("img")
    img.setAttribute("src","https://naildrivin5.com/images/DavidCopeland-old.jpeg")
    img.setAttribute("alt","Old Profile photo of Dave Copeland")
    img.setAttribute("width","64")
    img.setAttribute("title","Younger Dave Copeland")
    element.appendChild(img)
    const userAvatar = new UserAvatar(element)
    section.appendChild(userAvatar.element)
    userAvatar.enhance()
  })
})

Yech. You can see this working on CodePen. Sure enough, when the dynamic component is added, the enhancement runs. There are a lot of ways to make this better, but that’s not the point of this post.

Let’s see Jim Neilsen’s custom element do this.

Automatic Lifecycle Management

The additional code to handle the button is similar to what I used in my vanilla version (again, bear with me on the markup duplication—that can be eliminated and we’ll discuss how in a future post):

document.querySelectorAll("[data-add-new]").forEach((e) => {
  e.addEventListener("click", (event) => {
    event.preventDefault();
    const section = document.querySelector("section");
    section.insertAdjacentHTML(
      "beforebegin", `
<user-avatar>
  <img src="https://naildrivin5.com/images/DavidCopeland-old.jpeg"
       alt="Old Profile photo of Dave Copeland"
       width="64"
       title="Younger Dave Copeland"
       />
</user-avatar>`);
  });
});

If you run this on CodePen, it…just works. The reason is connectedCallback(). This is documented as running “when the element is added to the document”, and the words add and document mean something specific. It means when the element is dynamically put into the Document being shown, connectedCallback() is called.

This is a significant savings. We could create helper functions or classes that allow our vanilla JS version to work like this, but that would be some made-up, non-standard thing.

Revising the Four Steps to be Five

In my previous post, I outlined four steps that any JavaScript has to handle:

  1. Locate the elements of the document that will need to change.
  2. Identify the events you want to respond to.
  3. Ensure all input and configuration needed to control behavior is available.
  4. Wire up the events to the document based on the inputs and configuration.

It seems that there should be a new step:

  1. Ensure proper initialization and deinitialization when the document is dynamically manipulated.

With this fifth step, there is now a clear difference to use a custom element:

Step Web Components Vanilla
1 - Locate querySelector + defensive if statements querySelectorAll with specific selectors + defensive if statements
2 - Events N/A, but presumably querySelector, defensive if statements, and addEventListener N/A, but presumably querySelector, defensive if statements, and addEventListener
3 - Configuration getAttribute + defensive if statements getAttribute + defensive if statements
4 - Integration Code inside connectedCallback Code inside forEach
5 - Initialize/De-initialize Code inside connectedCallback or disconnectedCallback. A lot of non-standard code you have to write and manage.
  1. Locate
    • Web Components - querySelector + defensive if statements
    • Vanilla - querySelectorAll with specific selectors + defensive if statements
  2. Events
    • Web Components - N/A, but presumably querySelector, defensive if statements, and addEventListener
    • Vanilla - N/A, but presumably querySelector, defensive if statements, and addEventListener
  3. Configuration
    • Web Components - getAttribute + defensive if statements
    • Vanilla - getAttribute + defensive if statements
  4. Integration
    • Web Components - Code inside connectedCallback
    • Vanilla - Code inside forEach
  5. Initialize/De-initialize
    • Web Components - Code inside connectedCallback or disconnectedCallback.
    • Vanilla - A lot of non-standard code you have to write and manage.

What this tells me is that there is no real downside to Web Components, but some upside for situations when you will be adding or removing components dynamically. If you are using Hotwire (part of Rails), it works by sending server-rendered markup to the browser for dynamic insertion. This is a key benefit to that strategy.

Notably, React also provides a solution for this problem as its beuilt into the lifecycle of a component.

Why Wasn’t This Clear?

I think there are three reasons this isn’t clear:

  • I was anchored on a progressive enhancement scenario where there wasn’t any addition of custom elements.
  • The documentation around all this is…pretty bad. It’s pretty academic1 in that it presents information without contedxt, and doesn’t outline any real benefit for using it or reason it exists.
  • The other aspects of Web Components—Shadow DOM, templates, slots—don’t seem optimized for the decades-old use case of managing re-usable markup. We’ll dig into that in a future post.

I think the real approach is to not judge Web Components on a problem I think they should solve, but against the problem they were designed to solve. And that problem is a very tiny subset of the problems facing web developers. It’s almost too small to notice.


  1. 1I don't know about you, but almost every single class in college—undergrad and grad—boiled down to “presenting information to memorize but without any context for why it was useful to know”. Certainly high school was like this. I don't understand why educational systems are so afraid of practical appilcations or contextualizing information. ↩