Learn JavaScript The Right Way Javascript Icon

JavaScript is complex, unless you have all the answers...

Lifecycle Hooks in Web Components

Welcome back to The Ultimate Guide to Web Components blog series! We’re going to discuss the state of Web Components, help unravel best practices, reveal hidden tips and tricks and make sure that you get a real grasp on the workings of Web Components.

Articles in this series:

Web Components were officially introduced by Alex Russell at Fronteers Conference 2011 and Google began standardizing it in 2013. Today the technology has gained solid momentum with 10% of all page loads in Chrome consisting of webpages built with Web Components. Besides that, the big JavaScript frameworks are investigating ways to integrate Web Components, which opens up many avenues for code sharing and cross-framework compatibility. Web Components will grow even more popular in the not too distant future – that’s why it’s good to learn more about this amazing technology today! So here we are.

Today’s blog is all about lifecycle hooks and after reading it you’ll be primed with new knowledge:

What’s a lifecycle hook?

From the moment a custom element is created to the moment it is destroyed, many “things” can happen in the middle:

All of the above are called the element’s lifecycle, and we can hook into key events of its life with a number of callback functions, called ‘Custom Element Reactions’.

Custom Element Reactions are called with special care in order to prevent user’s code from being executed in the middle of a delicate process. They’re delayed to the point that all necessary steps are being executed and therefore look to be executed synchronously. To ensure that the hooks are invoked in the same order as their triggers, every custom element has a dedicated ‘custom element reaction queue’.

Lifecycle hooks are great, because you don’t have to invent a completely new system for constructing and deconstructing elements yourself. Most of the modern JavaScript Frameworks out there are providing similar functionality, but the big difference with web components is that you have native browser support. So you don’t need to load extra code into your application to be able to use them.

Which lifecycle hooks are available?

The following lifecycle hooks are available:

The following example shows an element that has all lifecycle hooks implemented:

class MyCustomElement extends HTMLElement {
  constructor() {
    super();
  }
  
  connectedCallback() {
    ...
  }
  
  disconnectedCallback() {
    ...
  }
  
  attributeChangedCallback(name, oldValue, newValue) {
    ...
  }
  
  adoptedCallback() {
    ...
  }
}

Let’s take a closer look at these technologies one-by-one.

constructor()

JavaScript is a functional programming language where everything is a function and the constructor is no exception, but its a bit different from other functions, because its used for creating and intializing ES6 classes and is called when an instance of an element is upgraded (when it’s created or a previously-created one becomes defined by calling the customElements.define() method).

A constructor can be used for creating an instance of the Shadow Dom, setting up event listeners and for intializing a component’s state, but it’s not recommended to execute tasks like rendering or fetching resources here. That should be deferred to the connectedCallback hook. More on that below.

Angular Directives In-Depth eBook Cover

Free eBook

Directives, simple right? Wrong! On the outside they look simple, but even skilled Angular devs haven’t grasped every concept in this eBook.

  • Green Tick Icon Observables and Async Pipe
  • Green Tick Icon Identity Checking and Performance
  • Green Tick Icon Web Components <ng-template> syntax
  • Green Tick Icon <ng-container> and Observable Composition
  • Green Tick Icon Advanced Rendering Patterns
  • Green Tick Icon Setters and Getters for Styles and Class Bindings

Defining a constructor when creating ES6 classes is actually optional. When undefined, the JavaScript engine will just initiate an empty constructor, but its actually mandatory when creating Custom Elements. This is because a Custom Element is custom (dûh!) and the prototype and constructor are defined by the user.

While defining a constructor, there are a few rules we need to follow:

More information about the rules can be found here.

connectedCallback()

When an element is added to the DOM, the connectedCallback method is triggered. From that moment we can be sure that its available in the DOM and we’re able to safely set attributes, fetch resources, run setup code or render templates. Therefore you should try to defer as much as work as possible to this point.

The connectedCallback hook can be triggered more than once during its lifetime! So be aware that code that needs to be executed only once is guarded.

Take a look at the following example, which triggers the hook twice:

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

customElements.define('my-custom-element', MyCustomElement);

const myCustomElement = new MyCustomElement();

document.body.appendChild(myCustomElement);
document.body.appendChild(myCustomElement);

// result:
// 'connected'
// 'connected'

It’s called twice, because Node.appendChild() moves the element from its current position to the new position which triggers the connectedCallback method. If this behavior becomes a concern, you can defer the operation to a requestAnimationFrame callback and cancel all future frames containing connectedCallback calls.

disconnectedCallback()

This lifecycle hook is triggered when the element is removed from the DOM and is the ideal place to add cleanup logic (the code that needs to be executed before the element is destroyed) and to free up resources. We can use this callback to:

If you don’t clean up your code properly, you might risk memory leakage (memory that the application no longer needs, but has not been returned to the operating system or memory pool).

Note that this hook is never called when the user closes the tab! Note that this hook can be triggered more than once during its lifetime!

The following example shows how the disconnectedCallback hook is triggered:

class MyCustomElement extends HTMLElement {
  ...
  
  disconnectedCallback() {
    console.log('disconnected from the DOM');
  }
}

customElements.define('my-custom-element', MyCustomElement);

document.querySelector('my-custom-element').remove(); // 'disconnected from the DOM'

attributeChangedCallback(attrName, oldVal, newVal)

One way to pass data to Custom Elements is via attributes, which can be assigned like this:

<my-custom-element 
  prop1="foo" 
  prop2="bar" 
  prop3="baz">
</my-custom-element>

And then each can be retrieved in our component like this:

class MyCustomElement extends HTMLElement {
  ...
  
  connectedCallback() {
    const prop1 = this.getAttribute('prop1'); // foo
    const prop2 = this.getAttribute('prop2'); // bar
    const prop3 = this.getAttribute('prop3'); // baz
  }
}

With this, we have a very straight-forward way to pass data to our Custom Elements, but there’s one small problem: The attributes are only read when the component is added to the DOM. We first have to remove and add it to the DOM in order to update them again. A waste of resources, but luckely attributeChangedCallback will be triggered when attributes are added, removed, updated or replaced or when an instance of a component is upgraded. Which attributes to keep track of is specified in a static get observedAttributes method that returns an array of attribute names. Any other attribute won’t be observed.

Note that to optimize performance (we don’t want to be flooded by all kinds of unnecessary attribute changes), only attributes listed in the static get observedAttributes method are observed.

The attributeChangedCallback lifecycle hook has three parameters:

The following example shows how to observe attributes:

class MyCustomElement extends HTMLElement {
  ...
  
  static get observedAttributes() {
    return ['prop1', 'prop2', 'prop3'];
  }
  
  attributeChangedCallback(name, oldValue, newValue) {
    console.log(`${name}'s value has been changed from ${oldValue} to ${newValue}`);
  }
}

adoptedCallback()

An element can be adopted into a new document (i.e. someone called document.adoptNode(element)) and has a very specific use case. In general, this will only occur when dealing with <iframe/> elements where each iframe has its own DOM, but when it happens the adoptedCallback lifecycle hook is triggered. We can use it to interact with the owner document, the main document or other elements.

Note that the element is not destroyed and created again while adopting, so the constructor() method won’t be called.

How to use lifecycle hooks in the real world?

Talking about hooks is all fun and games, but how can we use it in the real world? Let’s build a counter component <my-counter> to see all the hooks in action.

Requirements

The result:

// define the component's HTML template
const template = document.createElement('template');
template.innerHTML = `
  <style>
    button {
      width: 50px;
      height: 50px;

      border: 1px solid red;
      border-radius: 50%;

      background: tomato;

      color: white;
      font-weight: bold;

      cursor: pointer;
    }

    button:active {
      background-color: #D9391C;
    }

    span {
      display: inline-block;
      margin: 0 5px;
      min-width: 25px;

      text-align: center;
    }
  </style>

  <button id="increaseBtn">+</button>
  <span id="label"></span>
  <button id="decreaseBtn">-</button>
`;

export class CounterComponent extends HTMLElement {
  // define the observedAttributes array 
  static get observedAttributes() {
    return ['value', 'step'];
  }

  // define getters and setters for attributes
  get value() {
    return this.getAttribute('value');
  }

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

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

  set step(val) {
    if (val) {
      this.setAttribute('step', val);
    } else {
      this.removeAttribute('step');
    }
  }

  // define properties to store references to DOM elements in the component's template
  $increaseButton;
  $decreaseButton;
  $label;

  constructor() {
    // always do a super() call first to ensure that the component inherits the correct prototype chain and all properties and methods of the class it extends. 
    super();

    // optional: Attach Shadow DOM to the component
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.appendChild(template.content.cloneNode(true));

    // set references to the DOM elements from the component's template
    this.$increaseButton = this.shadowRoot.querySelector('#increaseBtn');
    this.$decreaseButton = this.shadowRoot.querySelector('#decreaseBtn');
    this.$label = this.shadowRoot.querySelector('#label');
  }
  connectedCallback() {
    // add event listeners on both buttons
    // we bind "this" to the callback of the listener to attach the component's scope.
    this.$increaseButton.addEventListener('click', this._increase.bind(this));
    this.$decreaseButton.addEventListener('click', this._decrease.bind(this));
  }
  disconnectedCallback() {
    // remove event listeners on both buttons
    this.$increaseButton.removeEventListener('click', this._increase.bind(this));
    this.$decreaseButton.removeEventListener('click', this._decrease.bind(this));
  }

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

  adoptedCallback() {
    console.log('I am adopted!');
  }

  _increase() {
    const step = +this.step;
    const value = +this.value;
    this.value = String(value + step);
  }

  _decrease() {
    const step = +this.step;
    const value = +this.value;
    this.value = String(value - step);
  }
}

customElements.define('my-counter', CounterComponent);

You can use the component like this:

  <my-counter value="100" step="2"></my-counter>

Closing thoughts

From the moment a custom element is created to the moment it is destroyed, many “things” can happen and with lifecycle hooks we can hook into those events with a number of callback functions, called ‘Custom Element Reactions’. Lifecycle hooks are great, because you don’t have to invent a completely new system for constructing and deconstructing elements yourself and provides enormous control over your components behavior.

Related blogs 🚀

Free eBooks:

Angular Directives In-Depth eBook Cover

JavaScript Array Methods eBook Cover

NestJS Build a RESTful CRUD API eBook Cover