Introduction guide to web components

Published on

Web components let you create reusable, sharable custom elements in web pages, which can encapsulate functionality internally. They're a little bit like a React component or a Vue component, but they're fully supported in browsers and are an official HTML spec.

Web components have been around for several years - first proposed back in 2013. That is before React and Vue, back in the days of jQuery and bootstrap. But then they faded in the background.

They seem to have become more popular in the last few years, starting with when Edge using Chromium in 2020, which paved the way for the current state where all major browsers support web components.

If you used web components back in 2019 or 2020 you probably had tons of issues with browser compatibility. But now we are in 2022 they're well supported and more powerful.

What are web components

3 main parts to web components

There are three main concepts when it comes to web components:

Shadow DOM

The shadow DOM is like the normal DOM you're used to, but it is rendered completely separately. You can set CSS to things in the shadow DOM and it won't 'leak' out to other elements elsewhere on the document.

One (quite inaccurate but the basic idea is the same) way to think about it is like how content in an iframe does not affect content outside of an iframe. It is sort of like that. Except its like a normal HTML element in the DOM.

Custom elements

You use JS APIs to create a custom element.

Web component custom elements always have a dash in them (you could define one called <heading-greeting /> for example. This is sometimes referred to as 'kebab-case'.

HTML templates

Web components can use <template> and <slot> to define the HTML markup for the custom element.

Simple web components example

Here is a self contained HTML element you can use in your HTML (after registering it with customElements.define()) that will say 'loading', then after 1 sec it will change its message.

class HeadingGreeting extends HTMLParagraphElement {
  constructor() {
    super();

    const shadow = this.attachShadow({mode: 'open'});
    const text = document.createElement('span');
    text.textContent = 'Loading...';
    shadow.appendChild(text);

    setInterval(function() {
      text.textContent = 'Loaded...';
    }, 1000);
  }
}


customElements.define('heading-greeting', HeadingGreeting, { extends: 'h1' });

How to write a custom web component

Web components are defined by extending the HTMLParagraphElement class, then calling customElements.define('your-component', YourComponent).

In the class's constructor you can set up the web component. Always remember to call super() in there.

class SomeDemo extends HTMLElement {
  constructor() {
    super();

    // Add your functionality here
  }
}

Web components lifecycle callbacks

How to use lifecycle callbacks in web components

Use them as method names, such as:

class SomeDemoElement extends HTMLElement {
  constructor() {
    super();
    // put logic here
  }
  
  connectedCallback() {
    // put logic here
  }
  
  disconnectedCallback() {
    // put logic here
  }
  
  attributeChangedCallback(attributeName, oldValue, newValue) {
    // put logic here
  }
  
  adoptedCallback() {
    // put logic here
  }
}

connectedCallback

Every time a custom web component is added or moved in the DOM, the connectedCallback lifecycle event will fire.

This can also be called once the web component is no longer connected - so check this with Node.isConnected.

class LogWhenConnected extends HTMLElement {
  connectedCallback() {
    console.log('connected!');
  }
}

customElements.define('log-when-connected', LogWhenConnected);

const el = new LogWhenConnected();

document.body.appendChild(el);
document.body.appendChild(el);

// will output 'connected' twice

disconnectedCallback

Called when the web component is disconnected from the DOM.

note: It is not called when user closes a tab/window.

Use this function to add cleanup logic such as:

  • unsubscribe from event listeners
  • clear timers/intervals
  • notify other code that the element was removed
class LogWhenDisconnected extends HTMLElement {
  disconnectedCallback() {
    console.log('disconnected');
  }
}

customElements.define('log-when-disconnected', LogWhenDisconnected);
const el = new LogWhenDisconnected();
document.body.appendChild(el); // add to dom
document.querySelector('log-when-disconnected').remove(); // console log of 'disconnected' when remove from dom

adoptedCallback

Called when the web component is moved to new DOM/document. See Document.adoptNode https://developer.mozilla.org/en-US/docs/Web/API/Document/adoptNode

attributeChangedCallback

Called when the web component's element attributes are updated/added/removed. See also observedAttributes

For example if you had this in the dom:

<console-when-attr-change yourname="abc" />

And your component was defined as this:

class ConsoleWhenAttrChange extends HTMLElement {
  static get observedAttributes() {
    return ['yourname']; // watches changes for the 'yourname=""' attr
  }
  
  attributeChangedCallback(attributeName, oldValue, newValue) {
    console.log(`${attributeName} value changed from ${oldValue} to ${newValue}`);
  }
}

And you updated the attribute, you will see the console notifications

const el = document.querySelector('console-when-attr-change');
el.setAttribute('yourname', 'a new name');
// "yourname value changed from abc to a new name" in console

constructor

Not really a proper lifecycle callback but you use the constructor to initially set things up.

Example web component - a simple counter

const template = document.createElement('template');
template.innerHTML = `
    <style>
    button {
    background: red; /* note: it will only affect this <button>, not ones outside the web component */
    }
    </style>

    <span>0</span>
    <button>click to increment</button>
`;

class SimpleCounter extends HTMLElement {
  static get observedAttributes() {
    return ['value'];
  }

  get value() {
    return this.getAttribute('value');
  }

  set value(val) {
    this.setAttribute('value', val);
  }

  incButton;
  currentCounterValue;
  incFn;

  constructor() {
    super();

    this.attachShadow({
      mode: 'open'
    });
    this.shadowRoot.appendChild(template.content.cloneNode(true));
    this.incButton = this.shadowRoot.querySelector('button');
    this.currentCounterValue = this.shadowRoot.querySelector('span');
    this.incFn = () => {
      const value = Number(this.value) + 1;
      this.value = String(value);
    }
  }
  connectedCallback() {
    this.incButton.addEventListener('click', this.incFn);
  }

  disconnectedCallback() {
    this.incButton.removeEventListener('click', this.incFn);
  }

  attributeChangedCallback(name, oldValue, newValue) {
    this.currentCounterValue.innerHTML = newValue;
  }

}

customElements.define('simple-counter', SimpleCounter);

const el = new SimpleCounter();

document.body.appendChild(el);