You're (probably) using connectedCallback wrong
November 29, 2023 – @hawkticehurst
In what has become my biggest web component related TIL (today I learned) to date, you and I are almost definitely using the connectedCallback
lifecycle method incorrectly.
Let me explain.
Prompted by a recent discussion with Thomas Broyer, I was reminded that when setting up a web component you can take advantage of the fact that the constructor
is only run once to do things like configuring event listeners.
This, however, has always struck me as having a bad code smell because it is extremely common to see event handling code configured in connectedCallback
and correspondingly removed in disconnectedCallback
. If you look through the other responses to that thread and most web component code online, nearly everyone will configure their components in the following way:
class MyComponent extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
// Add event listeners when component is added to the DOM
this.addEventListener('click', this.clickHandler);
this.addEventListener('mouseover', this.mouseoverHandler);
}
disconnectedCallback() {
// Remove event listeners when component is removed from the DOM
this.removeEventListener('click', this.clickHandler);
this.removeEventListener('mouseover', this.mouseoverHandler);
}
clickHandler = () => {
// Handles click event
};
mouseoverHandler = () => {
// Handles mouseover event
};
}
But this, as I’ve discovered, is incorrect.
MDN got it wrong… kind of
I vividly remember learning, early on, that most component setup should be offloaded to the connectedCallback
lifecycle method. I found the original MDN article on custom elements (notably one of the very first I ever read about web components) where this information comes from. In the section on lifecycle methods, they say:
connectedCallback(): called each time the element is added to the document. The specification recommends that, as far as possible, developers should implement custom element setup in this callback rather than the constructor.
While it’s easy to casually assume what “custom element setup” entails, it’s not actually clear. And with Thomas’s suggestion that “most people do too much in connectedCallback,” I decided to go look at the spec.
In section 4.13.2 of the HTML Standard, I found my answer.
In general, work should be deferred to connectedCallback as much as possible—especially work involving fetching resources or rendering. However, note that connectedCallback can be called more than once, so any initialization work that is truly one-time will need a guard to prevent it from running twice.
In general, the constructor should be used to set up initial state and default values, and to set up event listeners and possibly a shadow root.
There it is… “event listeners [should be set up in the constructor].”
So while MDN was correct in saying that the spec recommends deferring work to connectedCallback
, it fails to clarify that this “work” does not include configuring event listeners –– something I had always assumed should be included.
According to the spec, we should be writing our web components like this:
class MyComponent extends HTMLElement {
constructor() {
super();
// Add event listeners when component is created
this.addEventListener('click', this.clickHandler);
this.addEventListener('mouseover', this.mouseoverHandler);
}
clickHandler = () => {
// Handles click event
};
mouseoverHandler = () => {
// Handles mouseover event
};
}
I have to admit, this actually makes sense! Event listener code is ostensibly something that should only be configured once when the component is first created. There’s really no need to reconfigure this anytime the component is removed and re-added to the DOM (such as when persisting a web component across a page transition using the View Transitions API) –– it’s unnecessary work.
Also for those worried about potential bloated memory issues, you probably* don’t need to be. Since this is configured in the constructor
method it follows regular JavaScript class semantics –– any state, default values, event listeners, shadow dom, etc. will be cleaned up when the class/component is no longer used.
*One big exception:
If you’ve configured any listeners (or any state for that matter) that live outside of your component (such as on the global window
object) you will need to manually add and remove references to those listeners, states, etc. when the component is added/removed from the DOM. These will not be automatically cleaned up when the component is no longer used.
class MyComponent extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
// Add event listener when component is added to the DOM
window.addEventListener('resize', this.handler);
}
disconnectedCallback() {
// Remove event listener when component is removed from the DOM
window.removeEventListener('resize', this.handler);
}
handler = () => {
// Handles window resize event
};
}
My new rule of thumb
This brings me to my new rule of thumb when building web components:
- Code that only needs to run once should be put in the
constructor
. - Code that needs to be run every time the component is added/removed from the DOM should be put in
connectedCallback
anddisconnectedCallback
.
I’ve got a lot of web component code to go back and update… 😅
How about you?