Components can have many things that make them useful, for example styles, templates, or other information. Some components might keep a memory of things - often called state. All these things need to be initialized using different techniques. You will likely use a combination of these techniques when building a component.
It can be very useful to create things during instantiation, that is when the component is first created. Most likely any state will want to be instantiated with each new component instance created. This can be accomplished with class fields or as logic during the constructor.
Class Fields
Components might have some state, much of which can be assigned using class fields. These could be public, private, or a combination of the two:
class StopWatchElement extends HTMLElement { static define(tag = "stop-watch") { customElements.define(tag, this) } #time = Date.now() endTime = this.#time + 30_000 }
Adding logic during instantiation
Classes can use the constructor()
method to perform extra logic during instantiation. Web Components are free to use
the constructor()
, but it's not necessarily the best way to initialize a component. If you can, use private or public
fields to set up state without having to use the constructor()
- it'll be easier to read. If your component has
additional set up logic, like adding event listeners, then the constructor()
isn't the best place for that - as the
Web Component isn't inserted into (or connected) to a DOM tree, and so it won't have a parent
. For that, you'll need
a lifecycle callback...
Adding logic when an element is inserted into the DOM
Perhaps more valuable than knowing when an element is created, is to know when it gets inserted into the DOM. At this point an element can look at its parent elements to know where it's in the tree, and can do useful logic like adding event listeners, and start rendering or animating.
If a custom element has a connectedCallback()
function, then the browser will automatically call this whenever the
element is inserted into (or connected) a DOM tree. This is sometimes called a lifecycle callback. You
can use the connectedCallback
to call other functions you have in your class, or to perform the elements set up.
class StopWatchElement extends HTMLElement { static define(tag = "stop-watch") { customElements.define(tag, this) } #time = Date.now() // This gets called automatically by the browser connectedCallback() { this.start() } start() { console.log("timer started") } }
It's a good idea to also make use of the disconnectedCallback()
function, which is another lifecycle
callback. The disconnectedCallback()
gets called whenever the element is removed (or disconnected) from
the DOM tree. Things you set up in the connectedCallback()
can be cleaned up in the disconnectedCallback()
:
class StopWatchElement extends HTMLElement { static define(tag = "stop-watch") { customElements.define(tag, this) } #time = Date.now() connectedCallback() { // Add event listeners when connected this.ownerDocument.addEventListener("keypress", this) } disconnectedCallback() { // Remove the registered event listeners when disconnected this.ownerDocument.removeEventListener("keypress", this) } }
Event listeners will stay active until they're removed. If you don't remove them in the disconnectedCallback()
, they
could still be active, which will result in errors when those events get triggered.
Advanced: use an AbortController in your disconnectedCallback()
Instead of setting up things in connectedCallback()
and tearing those down in the disconnectedCallback()
, you can
make use of an AbortController
to group the setup and tear down logic close to each other. You can set up an
AbortController
during the connectedCallback()
, and call .abort()
in the disconnectedCallback()
. Then, whatever
makes use of the signal will get cleaned up when the signal aborts.
Lots of APIs make use of signals, including addEventListener
. Take a look at the following two classes which are
have equivalent functionality, but one uses the AbortController
. For relatively few items set up in
connectedCallback()
, the extra set up of the AbortController
might result in more lines of code, but as you start
adding more events and actions, it can start to pay dividends:
class StopWatchElement extends HTMLElement { static define(tag = "stop-watch") { customElements.define(tag, this) } // Set up the class field, but set it to null for now #abortController = null connectedCallback() { // Make a new AbortController this.#abortController = new AbortController() const { signal } = this.#abortContoller // Pass the signal to addEventListener this.ownerDocument.addEventListener("keypress", this, { signal }) // Use a signal "abort" event to stop the timer signal.addEventListener("abort", () => this.stop(), { once: true }) this.start() } disconnectedCallback() { // All cleanup happens with this one line this.#abortController?.abort() } }
class StopWatchElement extends HTMLElement { static define(tag = "stop-watch") { customElements.define(tag, this) } connectedCallback() { this.ownerDocument.addEventListener("keypress", this, { signal }) this.start() } disconnectedCallback() { this.ownerDocument.addEventListener("keypress", this) this.stop() } }