A custom element at a house party

The life and times of a web component

Joeri Sebrechts

When first taught about the wave-particle duality of light most people's brains does a double take. How can light be two different categories of things at the same time, both a wave and a particle? That's just weird. The same thing happens with web components, confusing people when they first try to learn them and run into their Document-JavaScript duality. The component systems in frameworks are typically JavaScript-first, only using the DOM as an outlet for their visual appearance. Web components however — or custom elements to be precise — can start out in either JavaScript or the document, and are married to neither.

Just the DOM please

Do you want to see the minimal JavaScript code needed to set up an <x-example> custom element? Here it is:

 

No, that's not a typo. Custom elements can be used just fine without any JavaScript. Consider this example of an <x-tooltip> custom element that is HTML and CSS only:

For the curious, here is the example.css, but it is not important here.

Such elements are called undefined custom elements. Before custom elements are defined in the window by calling customElements.define() they always start out in this state. There is no need to actually define the custom element if it can be solved in a pure CSS way. In fact, many "pure CSS" components found online can be solved by such custom elements, by styling the element itself and its ::before and ::after pseudo-elements.

A question of definition

The CSS-only representation of the custom element can be progressively enhanced by connecting it up to a JavaScript counterpart, a custom element class. This is a class that inherits from HTMLElement and allows the custom element to implement its own logic.

What happens to the elements already in the markup at the moment customElements.define() is called is an element upgrade. The browser will take all custom elements already in the document, and create an instance of the matching custom element class that it connects them to. This class enables the element to control its own part of the DOM, but also allows it to react to what happens in the DOM.

Element upgrades occur for existing custom elements in the document when customElements.define() is called, and for all new custom elements with that tag name created afterwards (e.g. using document.createElement('x-example')). It does not occur automatically for detached custom elements (not part of the document) that were created before the element was defined. Those can be upgraded retroactively by calling customElements.upgrade().

So far, this is the part of the lifecycle we've seen:

<undefined> 
    -> define() -> <defined>
    -> automatic upgrade() 
                -> constructor() 
                -> <constructed>
        

The constructor as shown in the example above is optional, but if it is specified then it has a number of gotcha's:

It must start with a call to super().
It should not make DOM changes yet, as the element is not yet guaranteed to be connected to the DOM.
This includes reading or modifying its own DOM properties, like its attributes. The tricky part is that in the constructor the element might already be in the DOM, so setting attributes might work. Or it might give an error. It's best to avoid DOM interaction altogether in the constructor.
It should initialize its state, like class properties
But work done in the constructor should be minimized and maximally postponed until connectedCallback.

Making connections

After being constructed, if the element was already in the document, its connectedCallback() handler is called. This handler is normally called only when the element is inserted into the document, but for elements that are already in the document when they are defined it ends up being called as well. In this handler DOM changes can be made, and in the example above the status attribute is set to demonstrate this.

The connectedCallback() handler is part of what is known in the HTML standard as custom element reactions: These reactions allow the element to respond to various changes to the DOM:

There are also special reactions for form-associated custom elements, but those are a rabbit hole beyond the purview of this blog post.

There are more gotcha's to these reactions:

connectedCallback() and disconnectedCallback() can be called multiple times
This can occur when the element is moved around in the document. These handlers should be written in such a way that it is harmless to run them multiple times, e.g. by doing an early exit when it is detected that connectedCallback() was already run.
attributeChangedCallback() can be called before connectedCallback()
For all attributes already set when the element in the document is upgraded, the attributeChangedCallback() handler will be called first, and only after this connectedCallback() is called. The unpleasant consequence is that any attributeChangedCallback that tries to update DOM structures created in connectedCallback can produce errors.
attributeChangedCallback() is only called for attribute changes, not property changes.
Attribute changes can be done in Javascript by calling element.setAttribute('name', 'value'). DOM attributes and class properties can have the same name, but are not automatically linked. Generally for this reason it is better to avoid having attributes and properties with the same name.

The lifecycle covered up to this point for elements that start out in the initial document:

<undefined> 
    -> define() -> <defined>
    -> automatic upgrade() 
                -> [element].constructor()
                -> [element].attributeChangedCallback()
                -> [element].connectedCallback() 
                -> <connected>
        

Flip the script

So far we've covered one half of the Document-JavaScript duality, for custom elements starting out in the document, and only after that becoming defined and gaining a JavaScript counterpart. It is however also possible to reverse the flow, and start out from JavaScript.

This is the minimal code to create a custom element in JavaScript: document.createElement('x-example'). The element does not need to be defined in order to run this code, although it can be, and the resulting node can be inserted into the document as if it was part of the original HTML markup.

If it is inserted, and after insertion the element becomes defined, then it will behave as described above. Things are however different if the element remains detached:

The detached element will not be automatically upgraded when it is defined.
The constructor or reactions will not be called. It will be automatically upgraded when it is inserted into the document. It can also be upgraded explicitly by calling customElements.upgrade().
If the detached element is already defined when it is created, it will be upgraded automatically.
The constructor() and attributeChangedCallback() will be called. Because it is not yet part of the document connectedCallback() won't be.

By now no doubt you are a bit confused. Here's an interactive playground that lets you test what happens to elements as they go through their lifecycle, both for those in the initial document and those created dynamically.

Here are some interesting things to try out:

I tried writing a flowchart of all possible paths through the lifecycle that can be seen in this example, but it got so unwieldy that I think it's better to just play around with the example until a solid grasp develops.

In the shadows

Adding shadow DOM creates yet another wrinkle in the lifecycle. At any point in the element's JavaScript half, including in its constructor, a shadow DOM can be attached to the element by calling attachShadow(). Because the shadow DOM is immediately available for DOM operations, that makes it possible to do those DOM operations in the constructor.

In this next interactive example you can see what happens when the shadow DOM becomes attached. The x-shadowed element will immediately attach a shadow DOM in its constructor, which happens when the element is upgraded automatically after defining. The x-shadowed-later element postpones adding a shadow DOM until a link is clicked, so the element first starts out as a non-shadowed custom element, and adds a shadow DOM later.

While adding a shadow DOM can be done at any point, it is a one-way operation. Once added the shadow DOM will replace the element's original contents, and this cannot be undone.

Keeping an eye out

So far we've mostly been dealing with initial setup of the custom element, but a major part of the lifecycle is responding to changes as they occur. Here are some of the major ways that custom elements can respond to DOM changes:

MutationObserver in particular is worth exploring, because it is a swiss army knife for monitoring the DOM. Here's an example of a counter that automatically updates when new child elements are added:

There is still more to tell, but already I can feel eyes glazing over and brains turning to mush, so I will keep the rest for another day.


Phew, that was a much longer story than I originally set out to write, but custom elements have surprising intricacy. I hope you found it useful, and if not at least you got to see some code and click some buttons. It's all about the clicking of the buttons.