How to build custom elements that work no matter what order things are loaded
⭐️ a blog post
TL;DR:
- Put all the important code in
connectedCallback
- Either don’t depend on any specific DOM parent being pre-defined, or
await customElements.whenDefined(...)
- Cleanup any listeners, etc in the
disconnectedCallback
for good hygiene
Custom elements are seemingly created in three steps:
- Subclass HTMLElement
- Define the tag name
- Element is constructed
Which seems simple and complete, but it turns out things can happen in a lot of different orders and aren’t nearly that simple.
I’ve done some testing for different scenarios to see when are an elements attributes set, when are the element’s children reachable, and when is the element’s parent reachable? I’m trying to document what I’ve learned below.
Example html I will use to work through this:
<one->
<two->
</two->
</one->
<one->
is a valid custom element name. The only rules are: 1) must start with a letter and 2) must have at least one hyphen (-).<t->
is a valid custom element name, for example.
Also, just in case you don’t know, a custom element’s class can have a
constructor
and it can have aconnectedCallback
method which will be called when the element is attached to a document. It’s similar to “on mount” in the react and related world.
There are a few scenarios or orders that custom elements might be defined and discovered:
- A tag’s name might be in the HTML returned from the server, but the subclass might not be defined yet in the custom elements registry
- A tag name might show up in the html only after the subclass is defined
- The element class might be constructed in javascript, unrelated to html parsing
- Elements might be defined in a non-deterministic order
A tag’s name might be in the HTML returned from the server, but the subclass might not be defined yet in the custom elements registry
If you normally put your <script>
s at the bottom of the <body>
, the this is your scenario.
The HTML parser will first construct a generic HTMLElement
since the tag’s name isn’t in the customElements
registry. The element is style-able by CSS and the CSS can even see if the element is :defined
or not (if it’s in the customElements
registry). This can cause a flash of undefined custom elements depending on how you implement the CSS and HTML for the component. Some devs choose to hide not(:defined)
elements to avoid this.
After some Javascript is loaded and defines the tag name to be a subclass of HTMLElement, the generic HTMLElement will automatically be upgraded and replaced. customElements.define('one-', OneElement)
will cause the DOM to look for any existing one-
elements and upgrade them right then.
When an element is upgraded from a generic one to a custom one, the elements children
are accessible in the class’s constructor
, connectedCallback
, etc – it’s a leaf-first situation. So this.children
will be accurate anywhere, anytime for this scenario.
This is not the end of the story, though. Because of how HTML is parsed and because scripts are run inline, it’s possible that there is more HTML to go through after the <script>
which defined OneElement
.
As the HTML parser encounters new <one->
elements, it will directly instantiate the OneElement
class right then. So half the page might work one way, the other half might work another way. (I recommend never letting this happen, it’s just confusing 😅)
This is identical to the next scenario…
A tag name might show up in the html only after the subclass is defined
If you put your <script>
s in the <head>
and you do not use defer
, then this is your scenario.
Since the OneElement
is already registered into customElement
, the HTML parser will directly instantiate a new OneElement
for each <one->
it encounters. There won’t be a time where the CSS selector not(:defined)
would match, there is not upgrade, and so there is not flash of undefined custom elements.
And the access to this.children
is the same; whether your code is in the constructor
, connectedCallback
, or anywhere else, this.children
should be what you expect.
However, there is one more scenario that isn’t related to HTML parsing…
The element class might be constructed in javascript, unrelated to html parsing
I’m sure you’ve had to write code like:
const div = document.createElement('div')
div.id = 'some-id'
const paragraph = document.createElement('p')
paragraph.innerHTML = 'Hello 👋'
div.append(paragraph)
// etc, etc...
return div
And it feels very tedious… well this can happen for custom elements as well.
One can use document.createElement('one-')
or new OneElement()
to make a new instance of the custom element… and it doesn’t have its children yet.
So in this scenario, it is not safe to look at this.children
or this.parentNode
in the constructor
… It also doesn’t have any of it’s attributes set… so you can’t really look for those attributes in the constructor
either… which can be really annoying.
However, there is good news, you can trust this.children
and all the other stuff in the connectedCallback
.
Try this to prove it to yourself (just try this in the console at any URL):
class A extends HTMLElement { connectedCallback() { console.log('connected!') } }
// if you try `new A()` right now, you will get an error. A custom element can be instantiated until it is defined in the registry.
customElements.define('a-', A)
let a = new A() // yay!
let div = document.createElement('div')
// luckily, this does not call the connectedCallback yet!
div.append(a)
document.body.append(div)
// "connected!" will be logged, the connectedCallback was finally called
As long as custom elements are instantiated, fully prepared, and then added to a document things will probably work out just fine for any code in the connectedCallback
.
One way I work around this sometimes is to accept attributes and children as optional arguments to the constructor, like this:
class FancyElement extends HTMLElement {
constructor(attributes, ...children) {
super()
if (attributes) {
for (const [key, value] of Object.entries(attributes)) {
this.setAttribute(key, value)
}
}
if (children.length > 0) {
this.replaceChildren(...children)
}
// OH yeah!
}
}
So now I can safely put setup code in the constructor, as long as every dev knows this is how this custom element needs to be used… I don’t do this often, but sometimes this is the easiest way to move forward. Really tho, just try to only ever put important code in the connectedCallback
if at all possible.
This is all fine for one custom element, but what if there are many custom elements and they are all defined at different times 🤔
Elements might be defined in a non-deterministic order
Using the same example from above, assuming none of the subclasses have been defined during HTML parsing:
<one->
<two->
</two->
</one->
The order the subclasses are defined really matters. If, for some reason, OneElement was going to do something that TwoElement depends on (like setup a database or something), then you better make sure to define OneElement first.
Having a direct dependency on a parent in the DOM is an anti-pattern, try not to do it unless absolutely necessary.
And, well, if you really want to make sure, then you can wait for the other custom element to be defined inside the connected callback:
export class TwoElement extends HTMLElement {
static define() {
customElements.define('two-', this)
}
async connectedCallback() {
await customElements.whenDefined('one-')
// now the parent is fully there 💪
}
}
This might look like it could end up in a deadlock if two tags were waiting on each other, but it won’t. connectedCallback() is not actually async and the DOM runtime will not await the promise this example returns, so things can work out concurrently just fine.
(You can also use this trick the other way: wait until you know all the custom elements that might be children are defined.)
Still, you can also just make sure things are defined in an outside-in order in your code. That is what I prefer since it’s less code for me to write and I need this rarely.
Is this all a mess?
Kinda, but I’m OK with it. Custom elements are, IMHO, not a easy as react-like components because they are alive, living in the DOM, and dealing with the real built-in HTML parser and renderer which have to deal with a lot more than just JSX… If you embrace a few conventions, then it normally all works out fine:
- Put all the important code in
connectedCallback
- Either don’t depend on any specific DOM parent being pre-defined, or
await customElements.whenDefined(...)
- Cleanup any listeners, etc in the
disconnectedCallback
for good hygiene
Good luck. Let me know if I made any mistakes or missed any other scenarios that are tricky with custom elements.