On this page

Custom Elements

In this part we will explore how custom elements work. We will not be looking into attributes, the shadow DOM, slots and other web component parts here, that will come later. For now we are only interested in understanding what is going on when you add a tag to HTML.

HTML before JavaScript

When a page starts to load the browser goes and fetches the many parts it requires, the HTML pages, the JavaScript files, the CSS styles, images, and data from APIs. While this is all going on the browser will try to show the page with the parts it has gotten.

The browser will find our custom element tag and link it to the JavaScript class. It will then create an instance of the class and call its constructor function.

But what happens if the HTML is loaded first and the JavaScript file, with the web component class inside, is delayed. What does the browser do then?

In the below example we will test this. The HTML has the <delayed-load> tag but the delayed-load.js file, that contains the web component JavaScript code, is not loaded. So there is no link between them, which makes the tag unknown to the browser. There is a Load button, that when pressed will import (load into the browser) the JavaScript file we need.

Before the Load button is pressed, the <delayed-load> element does not contain anything inside. It is empty. The browser just treats it like a standard undefined DIV element.

<!DOCTYPE html>
<html lang="en">
  <head>...</head>
  <body>
    <p>...</p>
    <button onclick="loadButtonClick()">Load</button>
    <delayed-load></delayed-load>
    <script type="module">...</script>
  </body>
</html>

After the Load button is pressed, the browser will import the JavaScript file, then it will do its magic and the result will be that the <delayed-load> element will end up with some text inside.

<!DOCTYPE html>
<html lang="en">
  <head>...</head>
  <body>
    <p>...</p>
    <button onclick="loadButtonClick()">Load</button>
    <delayed-load>custom element created</delayed-load>
    <script type="module">...</script>
  </body>
</html>

The browser does not wait around for all the web components to be loaded before it starts to render the page. It can handle undefined tags, leaving them blank and empty until the code linked to them gets loaded in.

Tag Names

There are some limitations to the name you can give your custom element tag. HTML tags in general are case insensitive, so <MY-TAG> is the same as <my-tag>, and even <My-Tag> is the same too. However, when calling the customElements.define function, to define your custom element, the name you give has some requirements you need to follow. The rules are as follows.

  • All characters used must be in lower case.
  • The first character must be within the range of "a" to "z".
  • There must be at least one "-" hyphen.
  • You can include the characters "." (period), "_" (underscore), all numbers and all lower case characters (between "a" to "z"), and a large collection of UNICODE characters which I will not list here.

There are also a list of keywords that are already predefined that you cannot use. These include the following.

  • font-face
  • font-face-src
  • font-face-uri
  • font-face-format
  • font-face-name
  • annotation-xml
  • color-profile
  • missing-glyph

Have a look at the example below. You can test creating some custom elements with valid and invalid tag names.

For the invalid tag names you will get the same error message.

SyntaxError: Failed to execute 'define' on 'CustomElementRegistry': "singleword" is not a valid custom element name

Define Tag Twice

To create a custom element you call the customElements.define function. But what happens if you call it more than once for the same custom element or for a tag that already exists (has be defined before). Take a look at the below example.

Creating the <test-tag> custom element for the first time works without any problems. But if you press the Create <test-tag> custom element button again, it will try to create the same tag and custom element again, and this time it will fail. The same problem will happen if you try to define a different custom element but with the same tag name.

NotSupportedError: Failed to execute 'define' on 'CustomElementRegistry': the name "test-tag" has already been used with this registry

You can not create (define) a custom element more than once. So you need to be careful where you call the customElements.define function. However, you can import the same JavaScript file more than once and it will not be repeatedly evaluated, so as long as you only call the function once for that tag in your source code (and not in another source file) then you should be fine.

But what happens if you are using different libraries from different places that happen to be using the same custom element tag name? One will work and the other will fail, depending on which one was loaded into the browser first. Well, sadly, there isn't anything you can do, apart from rewriting parts of the library yourself to give it unique tag names.

This does lead to an important issue about selecting good names for your custom element tags. If the custom element is going to be used by other people, then choose the tag names to be more unique. But if the custom elements are being used internally for a single project, then shorter more tailored names can be used. For example, a custom element that shows barcodes, something that you want to give away as a library, could be named <my-business-name-barcode>. A custom element button, showing the "Save" text, to be used on an internal project, could simply be called <button-save>.

Connected and Disconnected

Web components come with two callback functions that the browser calls when it is attached and detached from the DOM. These are connectedCallback and disconnectedCallback. To better understand when these functions are called, we need to perform a little test. Take a look at the following code for the custom element.

class ConnectedDisconnected extends HTMLElement {
  constructor() {
    super();
    window.customLogEvent('constructor');
  }
  connectedCallback() {
    window.customLogEvent('connectedCallback');
  }
  disconnectedCallback() {
    window.customLogEvent('disconnectedCallback');
  }
}
customElements.define('connected-disconnected', ConnectedDisconnected);

When a new instance of the custom element is created and the constructor function is called, we run the customLogEvent function. Later you will see that this function will show the log events on the page, but it also sends it to the browser console. Both the connectedCallback and disconnectedCallback functions log their events too.

In the HTML we will import the JavaScript file, but we will not use the tag as we have before. Instead will we create all the elements we need for our test using the createElement function and give the user to option to attach and detach it to and from the DOM.

There are two elements, a parent and our custom element as a child. We can attach and detach the parent from the DOM, and we can attach and detach the custom element from the parent. The results are interesting.

Attaching the custom element to the parent, that is not attached to the DOM, leads to the connectedCallback function not being called. However, if we then attach the parent to the DOM then the connectedCallback function is being called. You will see after playing with the example for a bit that the two callback functions are only ever used if the custom element is directly linked to the DOM itself.

Create Element and Constructor

You can use a custom element in a number of different way. The primary way is to use the tag within some HTML text. But you can create them using the document.createElement function. But in doing so you can break things.

It seems that using the document.createElement function puts the element into some type state where it is not really part of the DOM, so calling functions linked to the DOM will throw and error. These issues do not happen if the element is already part of the DOM, like when it is used with a tag.

Therefore, if your web component is likely to be used with the document.createElement function then you will need to be careful what you do in the constructor function. Here are some of the things you must do and cannot do.

  • You must call the super function first.
  • Do not use the return function with a value.
  • Do not use the document.write and document.open functions.
  • Do not inspect or change child elements.
  • Do not inspect or change the attributes.
  • Do not call the document.createElement function.

You will not see anything on the page but in the console area there will be an error logged.

Uncaught DOMException: Failed to construct 'CustomElement': The result must not have children

This is a DOM exception. It was unable to run our custom element's constructor function. Not sure what the children part has to do with anything though.

Connected, Disconnected and Events

There are limitations around the interaction of the DOM in the constructor function, so the question is, what can we do in the constructor and what should be moved into the connected and disconnected callback functions? Generally you should only put initialization code in the constructor, setting up variables, lists and so on. You should not be creating other elements, creating events, making API calls to the server, or anything that takes a long time to do.

However, that is in generally, and there are times you can bend the rules. If you create and attach a Shadow DOM in the constructor, then you can do DOM related tasks on it. But when it comes to handling events, we need to keep away from the constructor.

To look into events lets create a simple custom event that contains a number and two buttons, one to add and one to minus the number. The internal HTML of the custom element is going to be like this.

<div id="count">0</div>
<button id="add">Add</button>
<button id="minus">Minus</button>

In the connectedCallback function we set the internal HTML (if we haven't done so already), then get each of the elements using their ID values. After that we set the click events.

connectedCallback() {
  // Add internal HTML
  ...

  // Get Elements
  this._countElement = document.getElementById('count');
  this._plusElement = document.getElementById('add');
  this._minusElement = document.getElementById('minus');

  // Add click events
  this._plusElement.addEventListener('click', this._plusClickEvent);
  this._minusElement.addEventListener('click', this._minusClickEvent);
}
_plusClickEvent() {
  this._count++;
  this._countElement.innerText = this._count.toString();
}
_minusClickEvent() {
  this._count--;
  this._countElement.innerText = this._count.toString();
}

The click events are functions that are part of the custom element. In each one they have access to the instance of the custom element that the click event is connected to. They use the keyword "this" to access the instance. However this is not how it works automatically and therefore requires us to manually bind the click event functions to the custom element "this" object.

// Bind the click event functions to the "this" object
this._plusClickEvent = this._plusClickEvent.bind(this);
this._minusClickEvent = this._minusClickEvent.bind(this);

If we did not bind the click event functions then the "this" value inside the event functions would point to something else.

This all work fine, but we need to handle what happens when the custom element is removed from the DOM.

disconnectedCallback() {
  // Remove click events
  this._plusElement.removeEventListener('click', this._plusClickEvent);
  this._minusElement.removeEventListener('click', this._minusClickEvent);
}

When the custom element is removed from the DOM the disconnectedCallback event is called. In here we remove the click events from the add and minus button elements.

When it comes to the connected and disconnected callback functions, you want to view one as adding things and the other as removing them, and try to match them up both together. It is important when, adding events in the connected callback function, to also remove them from the disconnected callback function at the same time, so you do not forget. If you do add events and forget to remove them, then you will end up with memory leaks. Not always a bad thing, but not ideal.

In the below example you can attach and detach the custom element. You can detach it in a way that will remove the plus and minus element's click events, or you can detach it so that it keeps them, as if you had forgotten to add the code to do it. You can use this to monitor what type of memory leaks you could end up with.

After some tests, when detaching the custom element without removing the click events, it looks like there are two HTMLButtonElement objects still in memory that have not been removed.

Events and Arrow Functions

Before adding event functions inside our custom element, we had to bind them to the "this" object. But what if we use the arrow function => method instead? One of the benefits of arrow functions is that they automatically bind the given function to the "this" object. Lets see at what this would look like.

// Add click events
this._plusElement.addEventListener('click', (event) => {
  this._count++;
  this._countElement.innerText = this._count.toString();
});
this._minusElement.addEventListener('click', (event) => {
  this._count--;
  this._countElement.innerText = this._count.toString();
});

The functions look cleaner, and as long as you are not doing too much inside, it seems an ideal approach. However, there is a big problem. The event functions are never removed. The only place we can add these functions is in the connected callback function. So if the custom element is attach, detached and then attached again, it will create a duplicate click event. There would be two plus click events, each getting fired when the Plus button is pressed, and each one increasing the amount by 1.

In the above example you can see this happening. Press to attach the custom element and add and minus the number. Everything will look as it should. But detach and attach it again, and when you press the Add and Minus buttons, it will increase and decrease the count value by 2 instead of one.

Customised Elements

All the custom elements we have created are known as autonomous and have been derived from the HTMLElement class. But there is another type, a slightly different method of extending existing HTML tags. These custom elements are derived from other element classes.

class CustomAnchorElement extends HTMLAnchorElement {
  constructor() {
    super();
    this.innerText = 'Custom anchor element';
    this.href = '/';
  }
}
customElements.define('custom-a', CustomAnchorElement, { extends: 'a'});

The first line creates the class CustomAnchorElement but instead of deriving (extends) it from HTMLElement, this time we derive it from HTMLAnchorElement. This means we take on all the attributes and extra functions that are part of the anchor element. We set the inner text but we can also set the link address, the HREF attribute setting. The last extra thing that needs to be done, is to include and third parameter object to the customElements.define function. This only contains one setting, extends, which is used to state which tag name you are deriving your custom element from. In this case it is the <a> tag.

<a is="custom-a"></a>

The HTML is different too. Instead of using the tag as <custom-a></custom-a>, we are using the normal anchor tag name <a...></a>, but adding the "is" attribute with the name of our custom element tag.

The above example show some custom elements derived from an anchor, button and an input. I am not sure there are any good use cases for this approach but maybe some ideas will come to me in the future.

Base and Derived Elements

With autonomous custom elements you are limited to only being able to derive it from the HTMLElement class, but it is possible to create your own base class and derive from that. You can create a base custom element that is derived (extends) from HTMLElement and then create other custom elements that derive from your base one.

As you can see from the above diagram, the DerivedCustomElement is derived from BaseCustomElement, which itself is also derived from HTMLElement. So how does this look in code.

// This is the base custom element
class BaseCustomElement extends HTMLElement {
  constructor() {
    super();
    this.style.backgroundColor = 'lightgray'
    this.innerText = 'base custom element';
  }
}
customElements.define('base-custom-element', BaseCustomElement);

// Derived from <base-custom-element> element
class DerivedCustomElement extends BaseCustomElement {
  constructor() {
    super();
    this.innerText = 'derived custom element';
  }
}
customElements.define('derived-custom-element', DerivedCustomElement);

In this example we first create the BaseCustomElement class, which is derived from HTMLElement. Remember, for autonomous custom elements, we must end up having the base class been derived from the HTMLElement class. The base custom element sets its inner text and changing the background color. On its own it may not do must. Your own base class would perform a set of default functions or set some default styles, which all the derived custom elements will take on and benefit from.

We then create the DerivedCustomElement class which is derived from our base class. Here we are only setting the inner text. We are not touching the background color, but it will be set by the base class.

In this example you can see the two custom element in action. Normally you wouldn't use the base custom element on its own, only the custom element derived from it.

This could be a good approach if you have a group of very similar custom elements that all perform the same type of functions. For example, you could have a base button custom element, and then create a ButtonSave, ButtonUpdate, ButtonDelete custom elements. The base button could include all the button related functions while the derived custom elements just set the inner text as required. This would reduce the amount of code you would need to write.

Self Including Element

What happens if the custom element includes HTML that also contains its own tag. We need to see some code to understand what I mean.

class IncludeItself extends HTMLElement {
  constructor() {
    super();
  }
  connectedCallback() {
    this.innerHTML = '<include-itself></include-itself>';
  }
}
customElements.define('include-itself', IncludeItself);

Here we are creating the custom element IncludeItself and giving it the tag <include-itself>. When the custom element is attached to the DOM, it will set its inner HTML. The HTML contains the <include-itself> tag.

The custom element will continually include a copy of itself again and again forever, in an infinite tree of custom element within custom element. See what happens in the example below.

After creating and adding the custom element to the DOM the browser will throw an error.

Uncaught RangeError: Maximum call stack size exceeded.
  at IncludeItself.connectedCallback (include-itself.html:28:24)
  at IncludeItself.connectedCallback (include-itself.html:28:24)
  at IncludeItself.connectedCallback (include-itself.html:28:24)
  at IncludeItself.connectedCallback (include-itself.html:28:24)
  at IncludeItself.connectedCallback (include-itself.html:28:24)
  at IncludeItself.connectedCallback (include-itself.html:28:24)
  at IncludeItself.connectedCallback (include-itself.html:28:24)
  at IncludeItself.connectedCallback (include-itself.html:28:24)
  at IncludeItself.connectedCallback (include-itself.html:28:24)
  at IncludeItself.connectedCallback (include-itself.html:28:24)

It runs out of stack space. Though it is interesting that it goes on for 10 levels before reaching the limit. It does seem you could create a custom element that does include itself as long you have it stop including itself at some point. Still, you really should not have any need to do this yourself.