Sequentially Parallel

Web Components Part 2

You can find the full examples used in this blog series on Github

This is part 2 of my Web Components series:

In the last article we went through how to declare a Custom Element, how to configure the element through HTML attributes and how to style the element.

This time, we will start by adding some interactivity and state to a component.

Revisiting <my-counter>

The previous post starts by showing that you can define a counter component, but it does not explain how to implement it. If we use what we have learned before, we’ll come up with something like this:

class MyCounter extends HTMLElement {
  static get observedAttributes() {
    return ["value"];
  }

  constructor() {
    super();
    this.attachShadow({ mode: "open" });
  }

  connectedCallback() {
    this.#render({ counterValue: this.value });
  }

  attributeChangedCallback(_name, _oldValue, _newValue) {
    this.#render({ counterValue: this.value });
  }

  get value() {
    const n = Number.parseInt(this.getAttribute("value"), 10);
    return Number.isNaN(n) ? 0 : n;
  }

  set value(v) {
    const n = Number.parseInt(v, 10);
    this.setAttribute("value", Number.isNaN(n) ? 0 : n);
  }

  #render({ counterValue }) {
    this.shadowRoot.innerHTML = `
      <button id="decrease" type="button">Decrease</button>
      <span>${counterValue}</span>
      <button id="increase" type="button">Increase</button>
    `;
  }
}

customElements.define("my-counter", MyCounter);

You can then declare the component in your html, with a starting value:

<my-counter value="2"></my-counter>

If you change the value attribute (either in the browser developer tools or through javascript), the counter value will react accordingly and will change the rendered value.

// with the browser developer tools console open
const myCounter = document.querySelector("my-counter");
myCounter.value = 5;

// the counter now renders 5 instead of 2 (the prvious starting value)

Responding to input

The counter has two buttons but they don’t do much. We can make them work by attaching an event listener function for the click event.

But where should I do this?

Considering this custom element re-renders the entire component when the value attribute changes, you should do it at the end of the render function.

#render({counterValue}) {
  this.shadowRoot.innerHTML = // ...ommitted

  this.shadowRoot
    .getElementById("decrease")
    .addEventListener("click", (_event) => {
      this.value = this.value - 1;
    });
  this.shadowRoot
    .getElementById("increase")
    .addEventListener("click", (_event) => {
      this.value = this.value + 1;
    });
}

If you refresh the page and click the buttons, they will now increment and decrement the counter value accordingly.

Composing custom components

Now that we have a working counter component, we can use it for building other components.

For this example, we want to build a counter reaction component, which will display a message based on the value of our counter:

Here is a skeleton implementation:

import "./my-counter.js";

const ReactionStates = Object.freeze({
  Low: { message: "Now it's too low!" },
  Normal: { message: "Looks good." },
  High: { message: "It's way to high" },
});

class CounterReaction extends HTMLElement {
  #reactionState;
  #counterValue;

  constructor() {
    super();
    this.attachShadow({ mode: "open" });
    this.#reactionState = ReactionStates.Normal;
    this.#counterValue = 5;
  }

  connectedCallback() {
    this.#render({
      reactionMessage: this.#reactionState.message,
      counterValue: this.#counterValue,
    });
  }

  #render({ reactionMessage, counterValue }) {
    this.shadowRoot.innerHTML = `
      <p>${reactionMessage}</p>
      <my-counter value=${counterValue}></my-counter>
    `;
  }
}

customElements.define("counter-reaction", CounterReaction);

We import our my-counter component and initialize the CounterReaction internal state. We want the reaction component to start with a normal reaction and the value 5, which is somewhere in the middle of our reaction scale.

If you try to declare our <counter-reaction> component in the page it will look okay, but the reaction message is not changing when the counter changes.

Communicating between components

Our <counter-reaction> component can set the value of our <my-counter> component, since it is part of counter’s public API and it is exposed as an attribute.

However, the reaction component would like to know when counter value changes, so it can render the appropriate reaction message.

We can achieve by leveraging the DOM event model. If our counter fires an event when its value changes, the reaction component can then react to that event and render the correct reaction message.

If we change our <my-counter> component to fire an event called my-counter:change and set the content of that event to be the new counter value:

// in my-counter.js

this.shadowRoot
  .getElementById("decrease")
  .addEventListener("click", (_event) => {
    this.value = this.value - 1;

    // fire an event when the value change
    this.dispatchEvent(
      new CustomEvent("my-counter:change", {
        bubbles: true,
        // also bubble up the shadow DOM
        composed: true,
        detail: { newValue: this.value },
      })
    );
  });
this.shadowRoot
  .getElementById("increase")
  .addEventListener("click", (_event) => {
    this.value = this.value + 1;

    // here as well
    this.dispatchEvent(
      new CustomEvent("my-counter:change", {
        bubbles: true,
        composed: true,
        detail: { newValue: this.value },
      })
    );
  });

Then our reaction component can listen to the change event and act accordingly:

// in counter-reaction.js

// helper to signal we are updating the component
#update() {
  this.#render({
    reactionMessage: this.#reactionState.message,
    counterValue: this.#counterValue,
  });
}

connectedCallback() {
  this.#update();

  // listen to counter changes
  this.addEventListener("my-counter:change", (event) => {
    const counterValue = event.detail.newValue;

    if (counterValue > 10) {
      this.#reactionState = ReactionState.High;
      this.#counterValue = counterValue;
      this.#update();
    } else if (counterValue < 1) {
      this.#reactionState = ReactionState.Low;
      this.#counterValue = counterValue;
      this.#update();
    } else {
      this.#reactionState = ReactionState.Normal;
      this.#counterValue = counterValue;
      this.#update();
    }
  });
}

counter-reaction component demo

Wrapping up

For part 2, we used the learnings from part 1 to build an element that reacts user to input. In a custom element, you can add event listeners just like in plain vanilla JavaScript.

We then used this element to build a more complex element. The new element (the parent), was composed by an instance of our <my-counter> element (the child).

In order to communicate between these elements, we dispatch a custom event from our child element. The parent element can then listen to that event and react accordingly.

Next time, we will explore some gotchas and things I have learned when building custom elements while only using the vanilla APIs.