Skip to main content Accessibility Feedback

The different ways to instantiate a Web Component

As part of this ongoing series on Web Components, we’ve created our first Web Component, learned how to add options and settings, and learned how to progressively enhance Web Components.

Today, we’re going to learn about the different ways to instantiate them (and some of the challenges with each approach).

Let’s dig in!

“Instantiate” isn’t exactly the right word

I used the word “instantiate” in the title of this article, but technically that’s what happens when the constructor() method runs.

What I mean more specifically is setting up the Web Component: getting child elements, setting properties, injecting any additional HTML, modifying attributes, and adding event listeners.

There are a few different ways and places to do that, with pros and cons to each.

Inside the constructor()

This is what we’ve been doing for all of the articles in the series so far.

The constructor() is where we’ve put all the things. And generally, that works just fine!

It’s simple, easy-to-read, and keeps everything together in one spot.

/**
 * The class constructor object
 */
constructor () {

	// Always call super first in constructor
	super();

	// Instance properties
	this.button = this.querySelector('button');
	this.count = parseFloat(this.getAttribute('start')) || 0;
	this.step = parseFloat(this.getAttribute('step')) || 1;
	this.text = this.getAttribute('text') || 'Clicked {{count}} Times';

	// Listen for click events
	this.button.addEventListener('click', this);

	// Announce UI updates
	this.button.setAttribute('aria-live', 'polite');

}

It becomes a challenge, though, when you dynamically create and inject a Web Component into an existing UI.

Imagine we have an existing page, and sometime after it loads, we decide to create and inject a new <wc-count> element onto the page.

We’ll start by using the document.createElement() method to create the element. We’ll use the Element.innerHTML property to give it some content. Then, we can use the Element.append() method to inject it into the UI.

let app = document.querySelector('#app');
let counter = document.createElement('wc-count');
// 👆 The constructor() runs here...
counter.innerHTML = `<button>Clicked 0 Times</button>`;
app.append(counter);

This looks great! But… it throws an Uncaught TypeError:

Uncaught TypeError: Cannot read properties of null (reading 'addEventListener')

The constructor() method runs on the new <wc-count> element as soon as we create it with the document.createElement() method.

This kicks off all of the setup activities before we’ve used the Element.innerHTML property to add the required <button>. When we go to add our click event listener to this.button, we get an error because that button doesn’t exist yet.

Here’s a demo on CodePen.

Inside the connectedCallback() method

Web Components have a handful of lifecycle events that automatically run callback methods, if you specify them.

We’ll learn more about that in a future article, but one of those callback methods is the connectedCallback() method, which runs when the element is actually connected to the DOM.

If we move all of the setup functions from the constructor() to the connectedCallback() method, we no longer get the Uncaught TyperError.

/**
 * The class constructor object
 */
constructor () {

	// Always call super first in constructor
	super();

}

/**
 * Setup the Web Component when it's connected to the DOM
 */
connectedCallback () {

	// Instance properties
	this.button = this.querySelector('button');
	this.count = parseFloat(this.getAttribute('start')) || 0;
	this.step = parseFloat(this.getAttribute('step')) || 1;
	this.text = this.getAttribute('text') || 'Clicked {{count}} Times';

	// Listen for click events
	this.button.addEventListener('click', this);
	
	// Announce UI updates
	this.button.setAttribute('aria-live', 'polite');

}

Here’s another demo.

Does that mean we should always run our setup tasks in the connectedCallback() method?

Unfortunately, it’s not quite that easy.

If for some reason a developer appends the new <wc-count> element to the DOM and then adds the elements, we’ll get our same Uncaught TypeError.

let app = document.querySelector('#app');
let counter = document.createElement('wc-count');
app.append(counter);
// 👆 The constructor() runs here...
counter.innerHTML = `<button>Clicked 0 Times</button>`;

Here’s another demo showing this error.

A hybrid approach

You can work around this timing issue by moving all of the setup functions to a setup() method.

In it, I check that any required HTML elements exist before completing the setup, and bail early if they don’t. In this case, I make sure this.button exists.

I also like to set an ._instantiated property after the Web Component is setup, and check for it before running the setup() function, to avoid running the method twice.

/**
 * Setup the Web Component when it's connected to the DOM
 */
setup () {

	// Don't run twice
	if (this._instantiated) return;

	// Instance properties
	this.button = this.querySelector('button');
	if (!this.button) return;
	this.count = parseFloat(this.getAttribute('start')) || 0;
	this.step = parseFloat(this.getAttribute('step')) || 1;
	this.text = this.getAttribute('text') || 'Clicked {{count}} Times';

	// Listen for click events
	this.button.addEventListener('click', this);
	
	// Announce UI updates
	this.button.setAttribute('aria-live', 'polite');

	// Complete instantiation
	this._instantiated = true;

}

Then, you can run the method inside the constructor() and connectedCallback() methods.

/**
 * The class constructor object
 */
constructor () {

	// Always call super first in constructor
	super();

	// Setup the Web Component
	this.setup();

}

/**
 * Run methods when the element connects to the DOm
 */
connectedCallback () {

	// Setup the Web Component
	this.setup();

}

You can also call the method directly on custom element, if needed.

let app = document.querySelector('#app');
let counter = document.createElement('wc-count');
app.append(counter);
counter.innerHTML = `<button>Clicked 0 Times</button>`;
counter.setup();
// 👆 The constructor() runs here...

Here’s one last demo.

Which approach should you use?

Like all things web development, it depends.

  • If I’m writing a Web Component for myself or a client who always uses pre-rendered or server-rendered HTML, I’ll setup my Web Component on the constructor().
  • If the Web Component might be loaded asynchronously or in unpredictable ways, I’ll use the hybrid approach.
  • If the setup tasks get really long, I’ll use a setup() method with either approach.