Anatomy of a Web Component



This content originally appeared on dbushell.com (blog) and was authored by dbushell.com (blog)

Custom elements are the backbone of a “web component” if that’s your preferred term. Web components typically includes the additional baggage of being “composable UI” as found in JavaScript frameworks (whatever that means).

In this post I’ll dissect the basics of a custom element (or web component) as it relates to modern JavaScript syntax and design patterns. I’ve had a lot of success building web things with these ideas. This is not the most polished guide I’ve ever written but I hope you’ll find some gold within!

Class Files

Custom elements are JavaScript classes that extend the native HTMLElement class.

class Component extends HTMLElement {
  // Todo...
}

It doesn’t really matter what you call the class if you follow my practice. I use the generic Component for copy-paste simplicity. Then I practice:

  • One element per file
  • Import elements as used

Before I get into the inner workings, let’s quickly review how to load a custom element. At the end of my web pages I will import the elements used.

<script type="module">
import "/elements/component-one.js";
</script>

I might also import elements dynamically based on usage if I have many.

<script type="module">
const elements = ["component-one", "component-two", "component-three"];
for (const name of elements) {
  if (document.querySelector(name)) {
    import(`/elements/${name}.js`);
  }
}
</script>

If I’m building an app-like experience I might simply import a single top-level component and allow that file to import nested child components as required.

Unless you’re creating a multi-megabyte React monstrosity (please don’t) a build and bundle workflow is unnecessary and likely to be broken in six months. Minification is also overrated — I can guarantee those few bytes are the least of your concerns.

If your script must be loaded undeferred in the <head> for reasons out of your control there is nuance I’ll discuss later.

Back to the class, custom elements must be registered in the global customElements store. I prefer this technique using static declarations.

class Component extends HTMLElement {
  static tag = "component-one";
  static {
    customElements.define(Component.tag, Component);
  }
  // Todo...
}

The static block is executed once; the first time the file is imported. It keeps the definition code at the top of the file and I only edit the tag field.

Alternatively, it’s possible to define the element after the class declaration.

class Component extends HTMLElement {
  // Todo...
}

customElements.define("component-one", Component);

You’ll see that version in most tutorials. Technically it does the same thing. Personally I don’t like it because it pushes the definition to the bottom of the file. It’s a minor detail not worth sweating over.

Everything discussed above handles custom elements that exist in the HTML document when the page loads. It’s worth exporting the class so that elements can be created dynamically.

export default class Component extends HTMLElement {
  // Todo...
}

The default export can be imported under any name.

<script type="module">
import ComponentOne from "/elements/component-one.js";

const element = new ComponentOne();
document.body.append(element);
</script>

This is why I use the generic Component because it’s inconsequential. It’s possible to rename any import so it’s all just a matter of preference.

Constructor

Normally classes in JavaScript are initialised with the new keyword.

const element = new Component();

This is not necessary with custom elements that exist in the initial HTML. As soon as the browser sees the opening tag of an element it will call the class constructor.

<component-one>Hello, World</component-one>
class Component extends HTMLElement {
  static tag = "component-one";
  static {
    customElements.define(Component.tag, Component);
  }

  constructor() {
    super();
    console.debug(this.innerText);
  }
}

The this keyword within class methods references the HTML element (usually). The example above would log “Hello, World!” to the console.

super() must be called when using the constructor of a derived class. It’s too much to explain here, but it’s worth knowing that custom elements extend HTMLElement, which extends Element, which extends Node.

constructor() {
  super();
  console.debug(this instanceof HTMLElement);
  console.debug(this instanceof Element);
  console.debug(this instanceof Node);
}

All of these expressions will log true.

We could select the DOM node and confirm its type.

const element = document.querySelector("component-one");
console.debug(element instanceof Component);

Creating new custom element instances in JavaScript can be done in two ways.

const elementA = new Component();
elementA.innerHTML = "A";

const elementB = document.createElement("component-one");
elementB.innerHTML = "B";

document.body.append(elementA, elementB);

Using the new keyword requires the class to be imported. Using createElement can be done with no knowledge of the class and before it is even loaded. This can lead to FOUC (flash of unstyled content). If the Component class has been registered these two examples are equivalent.

It’s important to remember that the constructor method is only run once for each element created. Custom elements can be removed and reattached to the DOM. Lifecycle methods exist to handle those cases which I’ll discuss later.

The constructor is the perfect place to call attachInternals.

class Component extends HTMLElement {
  static tag = "component-one";
  static {
    customElements.define(Component.tag, Component);
  }

  #internals;

  constructor() {
    super();
    this.#internals = this.attachInternals();
  }
}

The attached element internals provides access to a state set. State can be queried by a CSS selector.

this.#internals.states.add("--large");
component-one:state(--large) {
  font-size: 2em;
}

Using a -- dashed ident prefix is not strictly required but CSS seems to be moving towards dashed idents. If you prefer not to use element internals then using data attributes can expose similar state to CSS.

this.dataset.large = "";
component-one[data-large] {
  font-size: 2em;
}

I assign internals to the private #internals field. This is only accessible inside the class and not as a property. The following code will log undefined.

const element = document.querySelector("component-one");
console.debug(element.internals);

If I were to remove the # prefix the property becomes accessible outside the class. You might see an underscore used in examples, e.g. _internals. Underscores have no special meaning in JavaScript. They were used only as a naming convention to suggest “private”. Today using # for real private fields is well supported in browsers.

CSS has a special :defined pseudo-class that indicates if a custom element has been properly registered. This is useful to reduce FOUC like the elementB example above.

component-one:not(:defined) {
  display: none;
}

Lifecycle Setup

Custom elements have a second method that is easily confused with the constructor.

class Component extends HTMLElement {
  static tag = "component-one";
  static {
    customElements.define(Component.tag, Component);
  }

  #internals;

  constructor() {
    super();
    this.#internals = this.attachInternals();
    console.debug("first");
  }

  connectedCallback() {
    console.debug("second");
  }
}

The connectedCallback method will run after the constructor. Unlike the constructor, it can execute again, and again, and any number of times for the same element instance.

const element = document.createElement("component-one");
document.body.append(element);
element.remove();
document.body.append(element);

In this example the connectedCallback is executed a second time after the element has been re-appended to the body.

Consider the following example.

const element = new Component();
element.innerHTML = "Hello, World!";
await new Promise(resolve => setTimeout(resolve, 1000));
document.body.append(element);

Now there is a one second delay between “first” and “second” being logged. What’s not obvious is that the elements child node (a “Hello, World!” text node) does not exist when the constructor executes. This is why the constructor is a bad place to setup child DOM.

Most custom elements require some kind of “setup” to add interactivity to their child nodes. The best place to do that is connectedCallback but we should be careful to avoid doing work twice.

class Component extends HTMLElement {
  static tag = "component-one";
  static {
    customElements.define(Component.tag, Component);
  }

  #internals;

  constructor() {
    super();
    this.#internals = this.attachInternals();
  }

  connectedCallback() {
    if (this.#internals.states.has("--ready")) {
      return;
    }
    this.#internals.states.add("--ready");
    // Todo...
  }
}

I use the internal state, but a data attribute, or even a boolean field on the class will achieve the same result. This state can also be used to avoid FOUC.

component-one:not(:state(--ready)) {
  display: none;
}

Earlier I mentioned a problem with undeferred scripts in the <head>. The constructor and connectedCallback will be called in immediate succession when an open tag is parsed. The child DOM is not ready yet. This can be fixed with a manual “defer”.

class Component extends HTMLElement {
  static tag = "component-one";
  static {
    customElements.define(Component.tag, Component);
  }

  #internals;

  constructor() {
    super();
    this.#internals = this.attachInternals();
  }

  connectedCallback() {
    if (document.readyState !== "loading") {
      this.#init();
      return;
    }
    document.addEventListener("DOMContentLoaded", () => this.#init());
  }

  #init() {
    if (this.#internals.states.has("--ready")) {
      return;
    }
    this.#internals.states.add("--ready");
    // Todo...
  }
}

This technique provides bulletproof assurance that the DOM has loaded. It’s effectively jQuery ready if you’re seasoned enough to remember that. This is good practice if you plan to share web components and cannot control loading.

Suppose I want to know if an element has been setup and is “ready” from elsewhere in JavaScript? I can use a “getter” method to provide a public property on the class.

class Component extends HTMLElement {
  static tag = "component-one";
  static {
    customElements.define(Component.tag, Component);
  }

  #internals;

  constructor() {
    super();
    this.#internals = this.attachInternals();
  }

  get isReady() {
    return this.#internals.states.has("--ready");
  }

  connectedCallback() {
    if (this.isReady) return;
    this.#internals.states.add("--ready");
    // Todo...
  }
}
const element = document.querySelector("component-one");
console.debug(element.isReady);

The isReady property will return true after the element has been added to the DOM. Getters are just fancy abstractions. Removing the get keyword gives you an isReady() method. Either way is useful to create a shorthand that protects private internal state.

Events and Teardown

Custom elements often need to listen for events outside of their DOM. Let’s use the window resize event as an example.

connectedCallback() {
  globalThis.addEventListener("resize", (event) => {
    console.debug(event.type);
  });
}

This introduces a problem if the element is removed from the DOM.

const element = document.querySelector("component-one");
element.remove();

Despite being removed the event listener is still active. To handle the “teardown”, or “cleanup” if you prefer, the disconnectedCallback method comes into play.

Below is one way to handle disconnection.

class Component extends HTMLElement {
  static tag = "component-one";
  static {
    customElements.define(Component.tag, Component);
  }

  connectedCallback() {
    globalThis.addEventListener("resize", this.#onResize);
  }

  disconnectedCallback() {
    globalThis.removeEventListener("resize", this.#onResize);
  }

  #onResize(event) {
    console.debug(this, event.type);
  }
}

If I was also using the isReady technique I’d need to add the event listener before checking isReady because it must be executed every time.

This works but there is a subtle bug — or at least a likely unintended side effect.

#onResize() {
  console.debug(this, event.type);
}

The value of this inside the event listener is not the element instance but the window object. We’ve lost reference to our element.

Some tutorials will fix this using bind.

connectedCallback() {
  this.onResize = this.onResize.bind(this);
  globalThis.addEventListener("resize", this.onResize);
}

This works, but notice I had to make onResize public. JavaScript doesn’t allow binding private methods this way. I strongly prefer keeping things private inside the class.

It might be tempting to use an inline anonymous function.

connectedCallback() {
  globalThis.addEventListener("resize", (event) => {
    this.#onResize(event);
  });
}

Here we’ve fixed the value of this but the event listener cannot be removed by disconnectedCallback because there is no reference to it!

A better solution is to turn the callback method into an anonymous private field.

class Component extends HTMLElement {
  static tag = "component-one";
  static {
    customElements.define(Component.tag, Component);
  }

  connectedCallback() {
    globalThis.addEventListener("resize", this.#onResize);
  }

  disconnectedCallback() {
    globalThis.removeEventListener("resize", this.#onResize);
  }

  #onResize = (event) => {
    console.debug(this, event.type);
  }
}

Note the new arrow function syntax for #onResize.

That works great, but we can take it one step further.

class Component extends HTMLElement {
  static tag = "component-one";
  static {
    customElements.define(Component.tag, Component);
  }

  #controller;

  connectedCallback() {
    this.#controller = new AbortController();

    globalThis.addEventListener("resize", this.#onResize, {
      signal: this.#controller.signal
    });

    globalThis.addEventListener("scroll", (event) => {
      console.debug("scroll");
    }, {
      signal: this.#controller.signal
    });
  }

  disconnectedCallback() {
    this.#controller.abort();
  }

  #onResize = (event) => {
    console.debug("resize");
  }
}

In the example above I’ve added an Abort Controller. This allows multiple event listeners to be removed in one action. It doesn’t matter if their callbacks can be referenced or not. Abort controller signals appear in other JavaScript APIs like fetch.

Asynchronicity

Let’s consider an RSS feed web component. This requires fetching data before the child DOM can be setup.

class Component extends HTMLElement {
  static tag = "rss-feed";
  static {
    customElements.define(Component.tag, Component);
  }

  #controller;
  #internals;
  #ready;
  #data;

  constructor() {
    super();
    this.#internals = this.attachInternals();
    this.#ready = Promise.withResolvers();
  }

  get ready() {
    return this.#ready.promise;
  }

  get isReady() {
    return this.#internals.states.has("--ready");
  }

  connectedCallback() {
    this.#controller = new AbortController();

    if (this.isReady) {
      this.#init();
      return;
    }

    fetch("https://dbushell.com/rss.xml", {
      signal: this.#controller.signal,
    }).then(async (response) => {
      this.#data = await response.text();
      this.#init();
    });
  }

  disconnectedCallback() {
    this.#controller.abort();
  }

  #init() {
    console.debug("every time setup");
    if (this.isReady) return;
    console.debug("one time setup");
    this.#internals.states.add("--ready");
    this.#ready.resolve();
  }
}

For the sake of a more readable example I’ve omitted error handling of fetch.

  • #init is delayed until fetch completes
  • #init can handle setup that happens once, or for every connectedCallback
  • After the request is successfully handled the ready promise remains resolved forever
  • If the element is removed before fetch completes the request is cancelled and tried again if the element is ever re-appended to the DOM

Like the earlier example, this adds the isReady boolean property to the element. It also adds the ready promise for asynchronous callback outside the class.

const element = document.querySelector("rss-feed");

element.ready.then(() => {
  console.debug(element.isReady);
});

The then callback can be used at any time. If the promise already resolved it will be called immediately (I say “immediately”, there is the whole event loop thing).

With this architecture the same abort controller can be used for both fetch requests and any temporary event listeners.

If promises scare you an event based architecture can be used instead. Using CustomEvent is fine but subclassing has benefits.

class ReadyEvent extends Event {
  static eventName = "ready";

  constructor() {
    super(ReadyEvent.eventName, {
      bubbles: true,
      composed: true,
    });
  }
}

bubbles propagates the event up the DOM through parent elements. composed pierces any shadow DOM boundaries as the event bubbles up.

class Component extends HTMLElement {
  static tag = "rss-feed";
  static {
    customElements.define(Component.tag, Component);
  }

  #controller;
  #internals;
  #data;

  constructor() {
    super();
    this.#internals = this.attachInternals();
  }

  get isReady() {
    return this.#internals.states.has("--ready");
  }

  connectedCallback() {
    this.#controller = new AbortController();

    if (this.isReady) {
      this.#init();
      return;
    }

    fetch("https://dbushell.com/rss.xml", {
      signal: this.#controller.signal,
    }).then(async (response) => {
      this.#data = await response.text();
      this.#init();
    });
  }

  disconnectedCallback() {
    this.#controller.abort();
  }

  #init() {
    console.debug("every time setup");
    if (this.isReady) return;
    console.debug("one time setup");
    this.#internals.states.add("--ready");
    this.dispatchEvent(new ReadyEvent());
  }
}

Using this event elsewhere in JavaScript is similar to the promise technique.

const element = document.querySelector("rss-feed");

element.addEventListener("ready", () => {
  console.debug(element.isReady);
});

One benefit of events is that you don’t need a direct reference to the element instance. The event can bubble up through parents and finally the window object.

globalThis.addEventListener("ready", (event) => {
  console.debug(event.target);
});

You can of course implement both events and promises. Promises are useful for handling many child components. The following pseudo-code could be the #init method of a parent component.

const ready = [];
for (const thing of imaginaryThings) {
  const child = document.createElement("imaginary-thing");
  this.shadowRoot.append(child);
  ready.push(child.ready);
}
await Promise.all(ready);
this.#internals.states.add("--ready");
this.#ready.resolve();
this.dispatchEvent(new ReadyEvent());

In this example multiple children are created and appended to the parent shadow DOM. The parent then awaits all children to asynchronously “ready” themselves in parallel before declaring itself ready.

That’s all for now!

I’ve only touch on the basics. These ideas work for light DOM, shadow DOM, and declarative shadow DOM custom elements. For my use cases, I’ve found little need to use attributes. Attributes can be useful for declarative configuration if you’re sharing a web component for others to use.

An event based architecture can allow a root component to use the reducer pattern common in JavaScript frameworks. Or you could use a state management library, subscribe to changes, and call a render method inside a component.

JavaScript bros would be shocked how far custom elements can take you at a fraction of the cost. But they’re too busy gaslighting themselves into believing a VC funded nightmare is essential. We know better!


This content originally appeared on dbushell.com (blog) and was authored by dbushell.com (blog)