What is WebComponents Buying Us?

November 17, 2023 📬 Get My Weekly Newsletter

People saying that “Web Components are having a moment” should look at the difference between Web Components and just using the browser’s API. They are almost identical, so much so that I’m struggling to understand the point of Web Components at all.

Let’s take Jim Neilsen’s user avatar example and compare his implementation to one that doesn’t use Web Components. This will help us understand why there is so much client-side framework churn.

Update on Nov 18, 2023: Added CodePens for all code + slight tweaks to the code to make it more clear what behavior is dynamic.

Here’s Jim’s code, slightly modified. HTML would look like so:

<user-avatar>
  <img
    src="/images/DavidCopelandAvatar-512.jpeg"
    alt="Profile photo of Dave Copeland"
    width="64"
    height="64"
    title="Dave Copeland"
  />
</user-avatar>

Jim then uses the Web Components API to add a fancier tooltip via progressive enhancement.

The call to customElements.define is what registers the custom element, which must extend HTMLElement. connectedCallback is part of HTMLElement’s API and is called by the browser when the element is “added to the document”.

<script>
  class UserAvatar extends HTMLElement {
    connectedCallback() {                 
      // Get the data for the component from exisiting markup
      const $img = this.querySelector("img");
      const src = $img.getAttribute("src");
      const name = $img.getAttribute("title");

      // Create the markup and event listeners for tooltip...

      // Append it to the DOM
      this.insertAdjacentHTML(
        'beforeend', 
        `<div>tooltip ${name}</div>`,
      );
    }
  }
  customElements.define('user-avatar', UserAvatar);
</script>

Here is the CodePen of this code. I did change Jim’s code slightly so you could see the effect of the custom element (the <div>).

Jim is making a case for progressive enhancement and showing how to do that with a custom element.

But, most of the code is using the browser’s API for DOM manipulation, which has existed for quite some time.

We could achieve the same thing without a custom element. We can add data-tooltip to the <img> tag to indicate it should have the fancy tooltip, like so:

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

To progressively enhance this, we can use the same code, without the use of custom elements:

<script>
document.querySelectorAll("[data-tooltip]").
  forEach( (element) => {
    // Get the data for the component from exisiting markup
    const $img = element.querySelector("img")
    const src = $img.getAttribute("src");
    const name = $img.getAttribute("title");

    // Create the markup and event listeners for tooltip...

    // Append it to the DOM
    element.insertAdjacentHTML(
      'beforeend', 
      `<div>tooltip ${name}</div>`
    );
  })

Here is the CodePen of this.

I’m struggling to see what the benefit is of the custom element. It doesn’t affect accessibility as far as I can tell. I suppose it stands out more in the HTML that something extra is happening.

We can see a bit more of the difference if we enhance these components to be more suitable for actual use in production.

Hello if Statements, My Old Friends

Jim said his code is for illustration only, so it’s OK that it doesn’t handle some error cases, but there is some interesting insights to be had if we handle them.

There are two things that can go wrong with Jim’s code:

  • If the <user-avatar> element doesn’t contain an <img> element, calls to getAttribute() will produce “null is not an object”.
  • If the <img> attribute is present, but is missing a src or name, presumably the tooltip cannot be created.

You can see both issues in this CodePen

Addressing these issues requires deciding what should happen in these error cases. Let’s follow the general vibe of progressive enhancement—and the web in general—by silently failing.

class UserAvatar extends HTMLElement {
  connectedCallback() {
    // Get the data for the component from exisiting markup
    const $img = this.querySelector("img");
    if (!$img) {
      return
    }
    const src   = $img.getAttribute("src");
    const title = $img.getAttribute("title");
     if (!src) {
       return
     }
     if (!title) {
       return
     }

    // Create the markup and event listeners for tooltip...

    // Append it to the DOM
    this.insertAdjacentHTML(
      'beforeend', 
      `<div>tooltip ${name}</div>`
    );
  }
}
customElements.define('user-avatar', UserAvatar);

(see CodePen version)

This has made the routine more complex, and I wish the browser provided an API to help make this not so verbose. This is why people make frameworks.

The vanilla version needs to perform these checks as well. Interestingly, it can achieve this without any if statements by crafting a more specific selector to querySelectorAll:

document.querySelectorAll("[data-tooltip]:has(img[src][title])").
  forEach( (element) => {
    // Get the data for the component from exisiting markup
    const $img = element.querySelector("img")

    const src  = $img.getAttribute("src");
    const name = $img.getAttribute("title");

    // Create the markup and event listeners for tooltip...

    // Append it to the DOM
    element.insertAdjacentHTML(
      'beforeend', 
      `<div>tooltip ${name}</div>`
    );
  })

(see CodePen version).

It is perhaps happenstance that this example can be made more defensive with just a specific selector. I don’t want to imply the vanilla version would never need if statements, but it certainly wouldn’t require any more than the Web Components version.

I fail to see the benefit of using a custom element. It doesn’t simplify the code at all. The custom element perhaps jumps out a bit more that something special is happening, but I don’t think it enhances accessibility or provides any other benefit to users or developers.

What Problem Are We Solving?

Whether you are using progressive enhancement or full-on client-side rendering, the job of the JavaScript is the same:

  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.

In the User Avatar example, both versions address these steps almost identically:

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

The reason these approaches are so similar is that the Web Components APIs aren’t a high level API for the existing DOM APIs, with one seldomly-needed exception1.

React, however, does provide such an API, but at great cost.

Step Web Components React
1 - Locate querySelector + defensive if statements React generates the HTML entirely.
2 - Events N/A, but presumably querySelector, defensive if statements, and addEventListener addEventListener + React's synthetic events (which are complex).
3 - Configuration getAttribute + defensive if statements Props hash with limited validations.
4 - Integration Code inside connectedCallback Code inside render
  1. Locate
    • Web Components - querySelector + defensive if statements
    • React - generates the HTML entirely.
  2. Events
    • Web Components - N/A, but presumably querySelector, defensive if statements, and addEventListener
    • React - addEventListener + React's synthetic events (which are complex).
  3. Configuration
    • Web Components - getAttribute + defensive if statements
    • React - Props hash with limited validations.
  4. Integration
    • Web Components - Code inside connectedCallback
    • React - Code inside render

React may look nicer in this analysis, but React comes at great cost: you must adopt React’s complex and brittle toolchain. If you want server-side rendering, that is another complex and brittle toolchain. If you want to use TypeScript to make props validation more resilient, that is a third complex toolchain, along with untold amounts of additional complexity to the management of your app.

React essentially elimilnates if statements from Step 1—locating DOM elements—at the cost of significant complexity to your project. It doesn’t offer much that’s compelling to the other steps we need to take to set up a highly dynamic UI.

Why is There no Standard API for This?

The browser has a great low-level API but no real higher level abstraction. It seems reasonable that the browser wouldn’t provide some high-level component-style framework, but that doesn’t mean it can’t provide a better API to wrap the lower-level DOM stuff. Who uses those APIs and doesn’t need to check the existence of elements or attributes that are required for their use-case?

A web page is a document comprised of elements with attributes. Those elements—along with the browser itself—generate events. This is what a web page is. Abstractions built on that seem logical, but they don’t exist in general, and the browser definitely is’t providing them.

React and friends are abstractions, but they are top down, starting from some app-like component concept that is implemented in terms of elements, attributes, and events. And those abstractions are extremely complex for, as we’ve seen, not a whole lot of benefit, especially if you are wanting to do progressive enhancement.

React and the like just don’t make it that much easier to locate elements on which to operate, register the events, manage configuration, and wire it all up. And they create a conceptual wrapper that doesn’t really help make accessible, responsive, fast web experiences. But you can see why they are there, because the browser has no answer.

Appendix on Shadow DOM and <template>

<template> exists and can be used to generate markup. This is only useful for client-side rendering and the Web Components API does not provide almost any additional features to manage templates. It will automatically use <slot> elements, so if you have this HTML

<template id="my-template">
  <h2>Hello</h2>
  <h3>
    <slot name="subtitle">there!</slot>
  </h3>
</template>

Assuming you manually load the template, manually clone it, manually add it to the Shadow DOM, it allows this:

<my-component>
  <span slot="subtitle">Pat</span>
</my-component>

to generate this HTML:

<h2>Hello</h2>
<h3>
  <span>Pat</span>
</h3>

You don’t have to locate the <slot> elements, or match them up and dynamically replace them.

Of note:

  • <slot>s cannot be used for attributes, only for elements.
  • You must use the Shadown DOM for this to work, and Shadow DOM adds constraints you may not want.

I can’t see why you would use slots, given all this. Having to adopt the Shadow DOM is incredibly constraining. You cannot use your site’s CSS without hacky solutions to bring it in.

See this in action in this CodePen.

As a vehicle for re-use, Web Components, <template>, <slot>, and the Shadow DOM don’t seem to provide any real benefit over the browser’s existing DOM-manipualtion APIs.


  1. 1The only rich API Web Components provides is attributeChangedCallback(), which provides a pretty decent way to be notified when your custom element's attributes have been changed. This can be achieved with MutationObserver, but it's much more verbose. Nevetheless, I struggle to identify a remotely common use-case for this, especially in light of the far more common use-case of "make sure an element I need to attach behavior to is in the DOM so I don't get 'undefined is not a function'".↩