My digital garden has an entire section with copy/paste code snippets and boilerplates.
I use these nearly every day when writing code. It helps me start working faster, and saves me from having to rewrite the same code over-and-over again.
I created web component boilerplate years ago. Since then, my approach to writing web component has changed quite a bit.
I just updated the boilerplate, adding everything I’ve learned from working on Kelp UI. Today, I wanted to explain how it works.
Let’s dig in!
Instantiate in the connectedCallback() method
I used to instantiate my web components (run all of the code to set them up) inside the constructor().
But when working with Kelp, I learned that this can throw errors, especially if you try to move or replace components with JS after they’ve instantiated.
I moved all of that code to the connectedCallback() method, which runs when the web component is connected to the DOM, and it fixed the issue.
/**
* Initialize on connect
*/
connectedCallback() {
// Startup code...
}
I also learned that you don’t need a constructor() at all if you do this, so I removed that method entirely from my boilerplate. There’s no need for it.
Waiting for DOM Ready
Web component JS gets loaded all sorts of different ways, and the DOM might yet be ready when the JS runs.
I’ve started checking the document.readyState first. If it’s not loading, I’ll instantiate immediately. If its still loading, I’ll set a one-time event listener for the DOMContentLoaded event and run my instantiations then.
To make that easier, I include an init() method that runs the actual instantiation code.
/**
* Initialize on connect
* Checks for DOM status first, ensuring code doesn't run before required
* elements exist in the DOM.
*/
connectedCallback() {
if (document.readyState !== 'loading') {
this.init();
return;
}
document.addEventListener('DOMContentLoaded', () => this.init(), {
once: true,
});
}
Private instance properties
With Kelp, I’ve started using private instance methods and properties a lot more.
For methods, you can just slap a hashtag (#) in front of the name. For instance properties, you need to declare them at the start of the class.
I’ve been using JSDoc to define types for these as well.
customElements.define(
'my-library',
class extends HTMLElement {
// Declare private instance properties
/** @type HTMLButtonElement | null */ #btn;
/** @type string */ #greeting;
/** @type HTMLElement */ #message;
// ...
});
The handleEvent() method
I make heavy use of the handleEvent() method for handling event listeners with my web components.
It makes it really easy to access instance properties and methods (this) inside the handlers. It also means that if you component gets removed and attached back to the DOM a few times, the callback method still only runs once without you needing to do any removal/cleanup.
connectedCallback() {
// ...
// Attach event listeners
this.#btn.addEventListener('click', this);
}
/**
* Handle events for the web component
* @param {Event} event
*/
handleEvent(event) {
if (event.type === 'click') {
this.#onClick(event);
}
}
No event cleanup
I use to remove every event I attached in the connectedCallback() method in the disconnectedCallback().
But the browser will automatically garbage collect events attached to the custom element and any of its child elements when its removed from the DOM when you use the handleEvent() method.
Now, the only ones I remove are events attached to elements outside the custom element, because they’re not automatically cleaned up.
connectedCallback() {
// ...
// Attach event listeners
document.addEventListener('input', this);
}
// Cleanup global event listeners on disconnect
disconnectedCallback() {
document.removeEventListener('input', this);
}
As a bonus for using handleEvent(), this is really easy to do because the handler is always this. No more caching callback methods for cleanup purposes.
No shadow DOM
It’s an anti-pattern that makes everything about using web components worse. Death to the shadow DOM!