semantic html

Mastering Web Components: A Comprehensive Step-by-Step Guide

Learn step-by-step how Web Components work and how to use them. Including HTML templates, custom elements, shadow DOM, attributes, properties, and slots.
If you're looking for a more visual and interactive way to learn about the topic of this post, check out my YouTube video on the same subject.

Why do they exist?

Web Components are the W3C’s answer to the rise of fronted JavaScript frameworks, where apps are divided into components that are composed together to form the UI. The classic way of developing vanilla HTML is to think in documents, so in the big picture of whole pages.

As frameworks like React and Angular require big JavaScript runtimes that every user has to download and execute, there was a need to make this component-based development style natively usable. This is why Web Components exit. I like to use them to extend existing web pages with small pieces of functionality. Oracle APEX, for example, allows you to develop plug-ins that integrate into the page. It does not force you to use Web Components, but I like to think about the components separately from the integration layer.

How do they work? - Step by step

We are developing a website for a pool repair company. On numerous pages, we need the dimensions of the customer’s pool to calculate how many materials we need and how much water we have to pump out. To not reinvent the wheel every time we need this functionality, we designed a small HTML layout that we can reuse on every page:

1<style>
2  .dimension-inputs {
3    display: grid;
4    grid-template-columns: 15ch 10ch;
5    row-gap: 0.4rem;
6  }
7  /* ... */
8</style>
9<div class="input-section">
10  <div class="dimension-inputs">
11    <label for="length">Length (meters)</label>
12    <input id="length" type="number" min="0" />
13    <label for="width">Width (meters)</label>
14    <input id="width" type="number" min="0" />
15    <label for="depth">Depth (meters)</label>
16    <input id="depth" type="number" min="0" />
17  </div>
18  <button id="calculate">Calculate</button>
19</div>
20<div class="output-section">
21  <label>Water Capacity (liters): </label>
22  <span id="capacity"></span>
23  <br />
24  <label>Surface Area (square meters): </label>
25  <span id="area"></span>
26</div>
27

How can we bundle this layout and the functionality behind it into a reusable component?

HTML Templates: Reausability

Copying and pasting this HTML layout everywhere is not ideal. When we want to change anything, we need to go through every page and do it there manually.

Luckily, we have the <template> tag where we can store this layout and implement it anywhere we need it. We can store it directly in the HTML file or in JavaScript. We can clone it with JavaScript to use the template:

1const template = document.createElement('template');
2template.id = 'pool-calculator-template';
3template.innerHTML = `
4<style>
5/* ... */
6</style>
7<div class="input-section">
8  <!-- ... -->
9</div>
10`;
11
12// get the div with the id app and append the template to it
13const appEl = document.querySelector('#app');
14const templateClone = template.content.cloneNode(true);
15appEl.appendChild(templateClone);
16

The HTML structure now includes a 1:1 clone of the template:

Custom elements: elegant API

Always running the cloneNode code works fine, but we can enhance this. We can create our own HTML element that includes the template and the cloneNode code. This way, we can use it like any other element in HTML:

1<body>
2  <div id="app">
3    <pool-dimensions></pool-dimensions>
4  </div>
5</body>
6

To make this work, we can create a class that extends HTMLElement. In the constructor, after calling super(), we clone the template and append it to the current element (this). Finally, we define the element with customElements.define() and pass the desired name:

1class PoolDimensions extends HTMLElement {
2  constructor() {
3    // always call super() first in the constructor
4    // runs constructor of parent class
5    super();
6    const templateClone = template.content.cloneNode(true);
7    this.appendChild(templateClone);
8  }
9}
10
11customElements.define('pool-dimensions', PoolDimensions);
12
Source

On the loaded page, we can see that the template is now the child content of the custom element.

Shadow DOM: Encapsulation

We defined styles and IDs in the template. There are two scenarios where this can be a problem:

  • Somewhere else on a page, the same ID or styles for the same class are used
  • With multiple instances of the custom element on the same page, the IDs are duplicated and thus not unique anymore

You could argue that you can make sure to use unique classes and IDs, but Web Components are supposed to be reusable and shareable. You can’t control how other developers use your components.

To solve this problem, we can use the Shadow DOM. It is a separate DOM tree that belongs to the custom element. To use it, we have to attach it to the custom element in the constructor:

1constructor() {
2  super();
3  // attach the shadow DOM to the custom element
4  this.shadow = this.attachShadow({ mode: "open" });
5
6  const templateClone = template.content.cloneNode(true);
7  // append template to the shadow DOM instead of "this"
8  this.shadow.appendChild(templateClone);
9}
10
Source

The difference between open and closed modes is that in open mode, we can access the shadow DOM from outside the custom element. In closed mode, we are not allowed to do this.

In the browser dev tools, we can see that the template is now a child of the shadow root.

If we want to access anything inside the Shadow DOM we have to first select the shadowRoot of our custom element and then search inside it. Again this only works when the Shadow DOM is in open mode:

1document.querySelectorAll('input');
2// -> []
3
4document.querySelector('pool-dimensions').shadowRoot.querySelectorAll('input');
5// -> [input#length, input#width, input#depth]
6

The Shadow DOM also encapsulates styles. If we add CSS to the parent page like input { color: lawngreen; } we find that the styles are not applied to anything inside the Shadow DOM. You also can’t use class names defined in the parent document inside the Shadow DOM.

Interactivity and Lifecycle

Until now, we haven’t really looked into how we can add logic to our custom element. It makes sense to also store this aspect inside the Web Component. As we are already in the JavaScript context, we can just add code to the class.

Event listener in constructor

We want our calculate button to do something. To achieve this, we can add an event listener to it in the constructor:

1class PoolDimensions extends HTMLElement {
2  constructor() {
3    // ...
4
5    this.shadow
6      .querySelector('#calculate')
7      .addEventListener('click', this.logClick);
8  }
9
10  logClick(e) {
11    console.log('clicked', e.target.textContent);
12  }
13}
14

Clean up event listener with lifecycle callback

Like in real life, we unfortunately need to clean up after ourselves. When the component is removed from the page, we should also remove the event listener. Web components have lifecycle callbacks that automatically run at certain points in the component’s lifetime. We can use the disconnectedCallback() that is called when the element is removed from the page:

1class PoolDimensions extends HTMLElement {
2  constructor() {
3    // ...
4
5    this.button = this.shadow.querySelector('#calculate');
6    this.button.addEventListener('click', this.logClick);
7  }
8
9  // runs when the element is removed from the page
10  disconnectedCallback() {
11    this.button.removeEventListener('click', this.logClick);
12  }
13
14  logClick(e) {
15    console.log('clicked', e.target.textContent);
16  }
17}
18

Better add event listener in connectedCallback

Unfortunately, there are some quirks with this approach. The constructor is called exactly once when the element is created. But when we, e.g., move an element, it actually gets disconnected and connected again. Then the disconnectedCallback runs, removing the event listener, but the constructor doesn’t run again. Now the button is not usable anymore.

We can fix this by adding the event listener in another lifecycle method instead of the constructor. The connectedCallback() is called every time the element is added to the page, unlike the constructor:

1class PoolDimensions extends HTMLElement {
2  constructor() {
3    // it is still safe to define dom references in the constructor
4    this.button = this.shadowRoot.querySelector('#calculate');
5  }
6
7  // runs when the element is added to the page
8  connectedCallback() {
9    this.button.addEventListener('click', this.logClick);
10  }
11
12  // runs when the element is removed from the page
13  disconnectedCallback() {
14    this.button.removeEventListener('click', this.logClick);
15  }
16
17  logClick(e) {
18    console.log('clicked', e.target.textContent);
19  }
20}
21
Source

My personal rule of thumb is to define variables in the constructor and run logic in the connectedCallback(). A more detailed explanation of what to care about can be found in this Stack Overflow question.

Calculation

Now we need to add the calculation code. We can just grab the input values, calculate the area and capacity, and show the result in the output fields. We can do this in a calculate function:

1constructor() {
2  // ...
3
4  // define dom references
5  this.calcBtn = this.shadowRoot.querySelector("#calculate");
6  this.lengthInput = this.shadowRoot.querySelector("#length");
7  this.widthInput = this.shadowRoot.querySelector("#width");
8  this.depthInput = this.shadowRoot.querySelector("#depth");
9  this.capacityOutput = this.shadowRoot.querySelector("#capacity");
10  this.areaOutput = this.shadowRoot.querySelector("#area");
11
12  // bind the calculate method to the class
13  this.calculate = this.calculate.bind(this);
14}
15
16connectedCallback() {
17  this.calcBtn.addEventListener("click", this.calculate);
18}
19
20disconnectedCallback() {
21  this.calcBtn.removeEventListener("click", this.calculate);
22}
23
24calculate() {
25  const length = this.lengthInput.valueAsNumber;
26  const width = this.widthInput.valueAsNumber;
27  const depth = this.depthInput.valueAsNumber;
28
29  const capacity = length * width * depth;
30  // walls + floor
31  const area = 2 * (width * depth + length * depth) + length * width;
32
33  this.capacityOutput.textContent = capacity;
34  this.areaOutput.textContent = area;
35}
36
Source

Notice how we redefine the calculate() method in the constructor. JavaScript and the this keyword are a confusing mess. Basically, what happens is that when we pass the method to the event listener, >>this<< will get overwritten by the addEventListener function to the triggering element. This results in us not being able to access the class properties anymore. To fix this, we have to bind the method to the class in the constructor. This will make sure that the this keyword will always refer to the class instance.

Component Attributes

One powerful thing about HTML is that we can easily add attributes to elements. Some are great for reference (id), some for styling (class, style), some for validation (required) and some for configuration (type, min, max).

1<input id="xyz" class="money" type="number" min="0" max="10" required />
2

We can also add custom attributes to our custom elements. That’s great because we have some international customers who, for some reason, use their feet to measure the pool dimensions instead of a measuring tape?! We can add a units attribute to switch between both units. This can be used like this:

1<pool-dimensions units="metric"></pool-dimensions>
2<pool-dimensions units="imperial"></pool-dimensions>
3

Modify Template

To achieve that, we first have to change our template so we can change the units in the labels. So we add spans with classes around each unit:

1<!-- ... -->
2<label for="length">Length (<span class="length-unit">meters</span>)</label>
3<!-- ... -->
4<label for="width">Width (<span class="length-unit">meters</span>)</label>
5<!-- ... -->
6<label for="depth">Depth (<span class="length-unit">meters</span>)</label>
7<!-- ... -->
8<label>Water Capacity (<span class="capacity-unit">liters</span>):</label>
9<!-- ... -->
10<label>Surface Area (<span class="area-unit">square meters</span>): </label>
11<!-- ... -->
12

Calculation

We can now add references to the labels in the constructor. We will also call a new method setUnits() in the connected callback:

1constructor() {
2  // ...
3  this.lengthTexts = this.shadowRoot.querySelectorAll('.length-unit');
4  this.capacityText = this.shadowRoot.querySelector('.capacity-unit');
5  this.areaText = this.shadowRoot.querySelector('.area-unit');
6}
7
8connectedCallback() {
9  // ...
10  this.setUnits();
11}
12

This new method will check the units attribute and set the labels accordingly. We access the attribute value with the getAttribute function.

1setUnits() {
2  const units = this.getAttribute("units") ?? "metric";
3
4  switch (units) {
5    case "metric":
6      this.isMetric = true;
7      break;
8    case "imperial":
9      this.isMetric = false;
10      break;
11    default:
12      console.warn(
13        `[${this.getAttribute("id")}] Invalid units attribute:`,
14        units
15      );
16      this.isMetric = true;
17  }
18
19  this.lengthTexts.forEach(
20    (el) => (el.textContent = this.isMetric ? "meters" : "feet")
21  );
22  this.capacityText.textContent = this.isMetric ? "liters" : "gallons";
23  this.areaText.textContent = this.isMetric ? "square meters" : "square feet";
24
25  // recalc
26  this.calculate();
27}
28

Additionally, we need to modify the water capacity calculation, as these people again use another unit called gallons (1000 of that unit don’t equal one cubic other unit; it’s strange). Fortunately, the area calculation is the same for both units, as it is just square unit in both cases:

1calculate() {
2    // ...
3
4    if (isNaN(capacity) || isNaN(area)) {
5      return;
6    }
7
8    if (!this.isMetric) {
9      // Convert cubic meters to gallons
10      this.capacityOutput.textContent = (capacity * 0.2642).toFixed(1);
11    } else {
12      this.capacityOutput.textContent = capacity;
13    }
14    this.areaOutput.textContent = area;
15  }
16

Reactivity

Currently, the units are defined at component creation and cannot be changed afterward. As our users are very indecisive, we need to allow them to switch their preferred units at any time. Luckily, this is not a problem for Web Components.

We first need to define which attributes should be observed for changes. We can just define a static getter observedAttributes that returns an array of attribute names:

1class PoolDimensions extends HTMLElement {
2  static get observedAttributes() {
3    return ['units'];
4  }
5
6  // ...
7}
8

Then we can use another lifecycle method called attributeChangedCallback. It receives the name of the changed attribute, the old value, and the new value. We can just call setUnits() when the unit is changed. We don’t need to pass anything, as the method will just read the attribute value again:

1attributeChangedCallback(name, oldValue, newValue) {
2  switch (name) {
3    case "units":
4      this.setUnits();
5      break;
6    default:
7      console.warn(
8        `[${this.getAttribute("id")}] Unhandled attribute change:`,
9        name
10      );
11  }
12}
13
Source

Now when we change the attribute value the setUnits() method will be called and the labels will be updated and a recalculation triggered.

Changing attributes from JavaScript

We can also change the attribute value dynamically in JavaScript. We need to query our custom element and then use the setAttribute() method:

1const el = document.querySelector('pool-dimensions');
2el.setAttribute('units', 'metric');
3el.getAttribute('units'); // 'metric'
4

Properties

This part is unfortunately a bit confusing. Next to attributes, there is another way to pass data to a component. This method is called properties.

Properties vs Attributes

Property values are not reflected in the DOM / HTML. You can only set them via JavaScript. Attributes only allow you to pass strings. The benefit of properties is that you can pass any JavaScript value to the component. This is useful for objects or arrays.

You could theoretically convert an object to a string, pass it as an attribute, and parse it back again in the component. But this approach is not ideal and looks horrible in the DOM.

The JavaScript API is fairly simple. You can access the properties literally via JavaScript properties (. operator) of the element:

1const el = document.querySelector('pool-dimensions');
2el.data = {
3  length: 2,
4  width: 3,
5  depth: 4,
6};
7console.log(el.data.length); // 2
8

Accessing porperties inside the component

Accessing the passed value is fairly simple, as the values are literally just properties on this:

1connectedCallback() {
2  /// ...
3  if (this.data) {
4    const { length, width, depth } = this.data;
5    this.lengthInput.value = length;
6    this.widthInput.value = width;
7    this.depthInput.value = depth;
8    this.calculate();
9  }
10}
11

You might also want to retrieve the current value of the inputs from outside after the user changes the inputs. We just have to define a getter function called get {propertyName}() that returns the current values:

1get data() {
2  return {
3    length: this.lengthInput.valueAsNumber,
4    width: this.widthInput.valueAsNumber,
5    depth: this.depthInput.valueAsNumber,
6  };
7}
8

We can also achieve reactivity by defining a setter:

1set data({ length, width, depth }) {
2  this.lengthInput.value = length;
3  this.widthInput.value = width;
4  this.depthInput.value = depth;
5  this.calculate();
6}
7
Source

Syncing properties and attributes

If we somehow forget that units is an attribute instead of a property, we can easily get confused, as the same key can have different values:

1const el = document.querySelector('pool-dimensions');
2el.getAttribute('units'); // 'metric'
3el.units = 'imperial';
4el.units; // 'imperial'
5el.getAttribute('units'); // 'metric'
6

We can keep both in sync by defining getters and setters for an attribute:

1set units(value) {
2  // if observed this will call attributeChangedCallback
3  this.setAttribute("units", value);
4}
5
6get units() {
7  return this.getAttribute("units");
8}
9
Source

But I prefer to do better documentation of my component to make it clear which values are attributes and which are properties than to add a hack.

Slots: passing child elements to a component

(From this point I won’t use the pool example anymore as it’s not fitting for the topic)

If you want to build a modal dialog UI component, you don’t care about the content of the modal. You just want to give it a title and some content. This is where slots are practical, which allow you to pass child elements to a component. You can define a slot in your template:

1<dialog open>
2  <header class="dialog-header">Title</header>
3  <main class="dialog-content">
4    <slot name="content"></slot>
5  </main>
6  <footer class="dialog-footer">
7    <button>Cancel</button>
8  </footer>
9</dialog>
10

And now, you can pass content to your custom component:

1<my-dialog>
2  <div slot="content">
3    <p>Some content</p>
4    <ul>
5      <li>Some</li>
6      <li>Content</li>
7    </ul>
8  </div>
9</my-dialog>
10

You can also dynamically pass content with JavaScript:

1const dialogContent = document.createElement('div');
2dialogContent.textContent = 'Some content';
3dialogContent.setAttribute('slot', 'content');
4
5const dialogEl = document.querySelector('my-dialog');
6dialogEl.appendChild(modalContent);
7
Source

As you can name slots you are even able to define multiple slots in your component.

Private and public methods

We want to open the dialog from outside the component, as we would like to bind the modal to a button. We could update the attribute on the component, but we can even call the open function from the outside. Any methods defined inside the component are public by default:

1// inside the component
2////////////////////////
3open() {
4  this.dialogEl.setAttribute("open", "");
5}
6////////////////////////
7
8
9
10// outside the component / main page
11////////////////////////
12const dialog = document.querySelector('my-dialog');
13
14btn.addEventListener('click', () => {
15  dialog.open();
16});
17////////////////////////
18

We can make methods private by prefixing them with a #:

1// inside the component
2////////////////////////
3#privateMethod() {
4  console.log("private method");
5}
6////////////////////////
7
8
9
10// outside the component / main page
11////////////////////////
12const dialog = document.querySelector('my-dialog');
13dialog.#privateMethod();
14// error:
15// Uncaught SyntaxError: Private field '#privateMethod' must be declared in an enclosing class
16////////////////////////
17
Source

Conclusion

I think Web Components are really useful and a great innovation in vanilla web development. But I still have some things that I don’t like about them. I will write about them in a future blog post and also show how development can be easier with some frameworks.

You can find all the demos on GitHub.

Comments

loading comments...