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:
- The Ultimate Guide to Web Components
- Lifecycle Hooks in Web Components (this post!)
Table of contents
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:
- A deep-level view and understanding of Web Component’s lifecycle hooks
- An overview of which hooks are available
- A deep understanding of how to use them
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:
- The element is inserted into the DOM
- It gets updated when a UI Event is being triggered
- An element can be deleted from the DOM
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:
- constructor()
- connectedCallback()
- disconnectedCallback()
- attributeChangedCallback(name, oldValue, newValue)
- adoptedCallback()
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.
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.
- Observables and Async Pipe
- Identity Checking and Performance
- Web Components <ng-template> syntax
- <ng-container> and Observable Composition
- Advanced Rendering Patterns
- 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:
- the first statement in a constructor needs to be a parameterless call to
super()
to make sure that the component inherits the correct prototype chain and all properties and methods of the class it extends. - It’s forbidden to use a
return
statement, unless its a simple early-return (return
orreturn this
). - We can’t use
document.write()
ordocument.open()
. - The element’s attributes and children should not be inspected, because they aren’t inserted into the DOM yet and therefore attributes aren’t available to inspect yet.
- In order to match user’s expectations when using
createElement()
orcreateElementNS()
, it’s forbidden to gain attributes or children in the constructor method.
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:
- notify another part of an application that the element is being removed from the DOM
- free resources that won’t be garbage collected automatically:
- unsubscribe from DOM events
- stop interval timers
- unregister all registered callbacks with global or application services
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:
name
: Represents the attribute’s nameoldValue
: Represents the old value of the attributenewValue
: Represents the new value of the attribute
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 counter component contains:
- an increase button to increase the current value
- a decrease button to decrease the current value
- a label to show the current value
- We need to be able to set the default value via an attribute “value”
- We need to be able to set the steps via an attribute “step”
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.