The web platform provides us with all the tools we need to apply reactivity to our web components. Let's explore some reactive programming patterns we can use in everyday scenarios to build interactive web pages and applications.

Keeping HTML element attributes and properties in sync

If you've worked with HTML elements previously you'll likely be aware that attributes declared on an element have no default mechanism to reflect their value to properties on the underlying object in JavaScript. While this isn't true for all elements and attributes it makes sense because object properties can represent any type of data and attributes are always represented as strings. Let's look at how we can keep attribute and property values in sync for situations that warrant it.

The HTMLElement dataset

The element dataset allows attributes to be set either from HTML in the data-* format or via the object property in JavaScript. Take the activePage property of the PageFlip element that we'll cover later in the article as an example. We could use the elements dataset to store it and provide an initial value in the HTML declaration.

<my-page-flip data-active-page="0"></my-page-flip>

If we set the value of the active page from JavaScript then it is reflected to the attribute in the HTML. You'll notice that the conversion between camel casing and hyphens is handled for us but because the dataset is represented as a DomStringMap it doesn't provide us any control over how the values are stored and exposed—it's always a string.

const flip = document.querySelector("my-page-flip");

flip.dataset.activePage = 1;
// <my-page-flip data-active-page="1"></my-page-flip>
console.log(flip.dataset.activePage);
// "1" (string)

This isn't perfect but it works fine for simple scenarios. Let's improve the solution by defining our own custom attribute and property.

Custom element attributes and properties

Rather than use the element dataset we can implement our own attributes and provide getter and setter functions to expose them as properties. Doing this provides us with full control over how the data is stored, provides better encapsulation and more control over how the element will react to changes. To implement this reactivity pattern we will observe attributes for changes by listing them in the static observedAttributes property (this could be applied to data-* attributes too). We then implement the attributeChangedCallback method to be notified about value changes and update the state accordingly.

Here's an example of this in the PageFlip element. We only want to update the attribute value in the HTML if it is already declared by conditionally calling setAttribute from the setter function and letting the callback handle updating the property value.

class PageFlip extends HTMLElement {
  // Observe changes to active-page attribute
  static observedAttributes = ["active-page"];

  // Initialize the state with a default value
  #activePage = 1;

  // Getter for the active page property
  get activePage() {
    return this.#activePage;
  }

  // Setter for the active page
  set activePage(pageIndex) {
    // If the attribute is set update it and let callback update state
    if (this.hasAttribute("active-page")) {
      this.setAttribute("active-page", pageIndex);
    }
    // No attribute set so just update state directly
    else {
      this.#setActivePage(pageIndex);
    }
  }

  // Update the value of the property when the attribute value changes
  attributeChangedCallback(name, oldValue, newValue) {
    if (name === "active-page" && oldValue !== newValue) {
      this.#setActivePage(parseInt(newValue, 10));
    }
  }

  #setActivePage(pageIndex) {
    // ...run animation so show page
    this.#activePage = pageIndex;
  }
}

Reacting to changes in sub-components

Custom elements (Web Components, I'll refer to them as components for the rest of the article) can define slots to inject content into the component tree at specified locations. I won't include the full implementation of each component here but you can view the code for the examples. When the element or elements in the slot update we may need to react to this change, let's explore some ways to deal with this reactivity pattern.

We'll implement a pagination component that provides controls for navigating through a list of pages. It'll need a template that defines the structure of the component with a default slot (the one that isn't named) to hold the pages, buttons to navigate to the next and previous page and a status indicator that displays the acttive page number alongside the total number of pages.

<template id="pagination-tmpl">
  <div id="pages">
    <slot><!-- default slot --></slot>
  </div>
  <div id="status"><!-- Page 1 of n --></div>
  <div id="controls">
    <button id="prev" aria-controls="pages">Previous</button>
    <button id="next" aria-controls="pages">Next</button>
  </div>
</template>

The component needs to react to changes in the default slot to set the total number of pages in the status label. It also needs to set and show the active page when pressing the buttons to navigate.

Using the slotchange event to react to updates

Whenever elements are assigned to a slot a slotchange event is dispatched. By adding an event listener for these we can update the status indicator to show the total number of pages. This may only happen once when the document is first loaded or multiple times depending on how the pages are displayed.

Let's start with a basic implementation of the pagination component. We'll add an event listener for the slot change and display the active page by setting the CSS display property of the pages.

class Pagination extends HTMLElement {
  // Currently selected page index
  #selectedPageIndex = 0;

  // Page selection indicator reference
  #selectionIndicator = null;

  // List of the page elements in the default slot
  #pages = null;

  connectedCallback() {
    // ...attach shadow root, apply the template and add button event listeners

    // Store a reference to the selected page indicator
    this.#selectionIndicator = this.shadowRoot.getElementById("status");

    // Listen for slot changes to store reference to the pages elements
    // and set the active page
    this.shadowRoot
      .querySelector("slot")
      .addEventListener("slotchange", (ev) => {
        this.#pages = ev.target.assignedElements();
        // Start by hiding all pages
        this.#pages.forEach((page) =>
          page.style.setProperty("display", "none"),
        );
        this.#setSelectedPage(this.#selectedPageIndex);
      });
  }

  #setSelectedPage(pageIndex) {
    // Display only the active page
    this.#pages[this.#selectedPageIndex]?.style.setProperty("display", "none");
    this.#pages[pageIndex]?.style.setProperty("display", "block");

    // Store the new active page index
    this.#selectedPageIndex = pageIndex;

    // Update indicator to show selected page
    this.#selectionIndicator.textContent = `Page ${pageIndex + 1} of ${this.#pages.length}`;
  }
}
Page one
Page two
Page three

This already works well to update the status indicator and display the active page. Next we'll add a component that animates the page changes and look at how we handle inter-component reactivity.

Using events for web component interactivity

If we move the responsibility of displaying the active page with an animation to a new PageFlip component we'll need it to provide a way to notify the Pagination component of changes to update the status indicator.

The PageFlip component will expose an activePage property (we saw this earlier) to control thie visible page when the navigation buttons are clicked. For the reactivity pattern we'll use a custom event named pagechange that is dispatched when the active page or total number of pages changes. The event is set to bubble so we can apply the listener to the slot element and not to the PageFlip component directly.

export class PageFlip extends HTMLElement {
  //... getter, setters and lifecycle methods

  #setActivePage(pageIndex) {
    // ...animate page into view

    // Store new page index and dispatch event
    this.#activePage = pageIndex;
    this.dispatchEvent(
      new CustomEvent("pagechange", {
        bubbles: true,
        detail: {
          activePage: this.activePage, // the property synced with active-page attribute
          totalPages: this.length, // property that exposes the number of page (children)
        },
      }),
    );
  }
}

Adding the page flip means the default slot now plays host to this component and the slot change event we used previously will be dispatched only when this element is assigned.

<my-pagination>
  <my-page-flip active-page="0">
    <div>Page one</div>
    <div>Page two</div>
    <div>Page three</div>
  </my-page-flip>
</my-pagination>

We'll update the Pagination component to remove the page display functionality and add the pagechange event listener. This component is simpler now and only responsible for navigating pages and setting the status indicator label.

class Pagination extends HTMLElement {
  // Page selection indicator
  #selectionIndicator = null;

  // Animation container reference
  #animationContainer = null;

  connectedCallback() {
    // ...attach shadow root, apply the template and add button event listeners to set activePage

    // Store a reference to the selected page indicator
    this.#selectionIndicator = shadow.getElementById("status");

    const slot = shadow.querySelector("slot");

    // Listen for slot changes and store reference to the animation container
    slot.addEventListener("slotchange", (ev) => {
      this.#animationContainer = ev.target.assignedElements()[0];
    });

    // Event listener for page change events to update the status indicator
    slot.addEventListener("pagechange", (ev) => {
      const { activePage, totalPages } = ev.detail;

      this.#selectionIndicator.textContent = `Page ${activePage + 1} of ${totalPages}`;
    });
  }
}
Page one
Page two
Page three

Events are fundamental to how we build user interfaces on the web with all kinds of existing events. By applying this pattern to our own components we are able to split our applications into discrete parts that can be combined together to create rich user experiences.

Using a MutationObserver to react to sub component updates

Sometimes, we might not have an event like pagechange available. Maybe we have to deal with integrating a new feature into a legacy codebase or use a third party library we have no control over. In these scenarios we can achieve a similar result with a MutationObserver to observe changes to the attributes and children of the element.

This approach here will rely on the active-page attribute correctly reflecting the activePage property value and some confidence that the list of pages are the elements direct children (although, we could also apply the observer to the entire element subtree).

Here's what the Pagination component would look like if we updated it to use a MutationObserver to control the status indicator instead of the pagechange event.

class Pagination extends HTMLElement {
  // Page selection indicator
  #selectionIndicator = null;

  // Animation container reference
  #animationContainer = null;

  // Mutation observer reference
  #observer = null;

  constructor() {
    super();

    // On mutations update the selected page status
    this.#observer = new MutationObserver(() => {
      this.#setSelectedPage();
    });
  }

  connectedCallback() {
    // ...attach shadow root, apply the template and add button event listeners to set activePage

    // Store a reference to the selected page indicator
    this.#selectionIndicator = shadow.getElementById("status");

    // Listen for slot changes to store reference to the page flip component
    // and observe for mutations within it
    shadow.querySelector("slot").addEventListener("slotchange", (ev) => {
      // Remove any previous observerations
      this.#observer.disconnect();

      this.#animationContainer = ev.target.assignedElements()[0];
      // Add the element to the observer for active-page attribute
      // and child list updates
      this.#observer.observe(this.#animationContainer, {
        attributes: true,
        attributeFilter: ["active-page"],
        childList: true,
      });

      this.#setSelectedPage();
    });
  }

  // Update the page status using the page flip component state
  #setSelectedPage() {
    const page = this.#animationContainer.activePage + 1;
    const totalPages = this.#animationContainer.length;

    this.#selectionIndicator.textContent = `Page ${page} of ${totalPages}`;
  }
}

Takeaway

Custom events are a power tool for implementing components that both work in isolation and can be combined to create dynamic applications for the web. Consider also the encapsulation of state through the use of custom property getters and setters and a pragmatic approach of reflecting this to attribute values and we have some solid patterns for adding reactivity to our component development.