DEV Community

Stuart Jones
Stuart Jones

Posted on • Originally published at horuskol.net

Making Web Components reactive

Welcome to part 3 on my experiments with creating native and light-weight, accessible and stylable Web Components.

This part is all about making our Web Component reactive, and working with that reactivity in a vanilla application, and
also in modern frameworks such as Vue.js and React.

What is reactivity?

If you've ever used a calculation in a spreadsheet to sum several rows of data, and then modified one of those rows, you
will have seen the calculated value immediately updated. The calculation is reacting to the change in inputs. As you
change the input, a signal informs the spreadsheet the input has changed, the spreadsheet then informs any calculations
that are using that input what the new value is, and the calculation is updated.

Similarly, if a web application has a toggle to change from light mode to dark mode, an event is fired when the toggle
is clicked on by the user. The event information usually includes the updated state of the toggle, and is
used by the application to inform the various parts of the application that need to know whether the user is in light or
dark mode, so that they can react accordingly.

For a Web Component such as the dropdown selector I've been building throughout this series to be useful, we need to be
able to read its properties and attributes, and detect when changes have been made so other parts of
the web application using the Web Component can react appropriately. Sometimes we may want the component to react to
events and data from other parts of the application.

First things first

As I work through making the dropdown-selector reactive in this article, I'm going to show examples of its use in
vanilla, Vue.js, and React.

Here's a quick run through on getting the (non-reactive) Web Component working in these three environments.

One thing that is common to all is how we define our custom element:

class DropdownSelector extends HTMLElement {
  constructor() {
    super()
      .attachShadow({mode: 'open'})
      .innerHTML = '<div>...</div>';
  }

  connectedCallback() {
    // stuff to do when <dropdown-selector> is added to the DOM and rendered
  }

  // all the rest of the API and behaviour of our element
}

// make the browser aware of our custom element and have it load in the component when we use <dropdown-selector>
customElements.define('dropdown-selector', DropdownSelector);
Enter fullscreen mode Exit fullscreen mode

Vanilla

Almost all modern and evergreen browsers allow the use of Web Components (with some caveats)
without any additional tooling or polyfills.

As covered in the previous parts, once we have defined our element, we can place it anywhere inside our HTML document:

<form>
    <label for="select-month">Choose a month</label>
    <dropdown-selector id="select-month">
        <option>January</option>
        <option>February</option>
    </dropdown-selector>
</form>
Enter fullscreen mode Exit fullscreen mode

Vue.js

Vue brings a little complexity - the newer version of Vue automatically resolves non-native HTML tags, and will emit a
warning if it can't resolve the tag to a Vue component.

To overcome this, we can add a hook into the compiler options
to skip resolving certain tags. However, I find the example in the
documentation to be a bit simplistic, especially as I've gotten into the habit of using kebab style tag names for my Vue
components over the last few years.

You can either list each custom element you're importing:

// vite.config.js or vue.config.js

compilerOptions: {
  // list all custom-elements
  isCustomElement: (tag) => [
    'dropdown-selector',
    // additional elements
  ].includes(tag)
}
Enter fullscreen mode Exit fullscreen mode

Or, you can use a prefix:

// vite.config.js or vue.config.js

compilerOptions: {
  // list all custom-elements
  isCustomElement: (tag) => tag.startsWith('awesome-')
}
Enter fullscreen mode Exit fullscreen mode

This would require that you change the custom element definition:

customElements.define('awesome-dropdown-selector', DropdownSelector);
Enter fullscreen mode Exit fullscreen mode
<template>
    <form>
        <label for="select-month">Choose a month</label>
        <awesome-dropdown-selector id="select-month">
            <option>January</option>
            <option>February</option>
        </awesome-dropdown-selector>
    </form>
</template>
Enter fullscreen mode Exit fullscreen mode

To ensure correct behaviour right from loading the page, define the custom element before you create the App in main.js:

customElements.define('dropdown-selector', DropdownSelector)

createApp(App).mount('#app')
Enter fullscreen mode Exit fullscreen mode

React

Using the custom element in React is much the same as in vanilla - with the usual allowance for React's syntax:

<form>
  <label htmlFor="select-month">Choose a month</label>
  <dropdown-selector id="select-month">
    <option>January</option>
    <option>February</option>
  </dropdown-selector>
</form>
Enter fullscreen mode Exit fullscreen mode

As with Vue, make sure you define the custom element before rendering the App in main.jsx:

customElements.define('dropdown-selector', DropdownSelector)

ReactDOM.render(
        <React.StrictMode>
          <App />
        </React.StrictMode>,
        document.getElementById('root')
)
Enter fullscreen mode Exit fullscreen mode

Web Component events

As it turns out, having our dropdown selector inform the rest of the application of when a user has changed the
selection is the easy part.

The first step is to add this to the select method in our selector, which
will trigger an event whenever the selected value changes in the dropdown:

select(index)
{
  //...

  if (this.__value !== this.__initialValue) {
    this.dispatchEvent(
            new Event('change')
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Because we are dispatching the event from the dropdown selector itself (this), the event target will point to the
selector and allow us to access its current state.

Now, if we want to update another part of our page (or send the value in an API request, or anything really), we need to
listen for the change and react to that information.

Vanilla

If we have an output in our document that we want to update whenever the selector's value changes:

<p id="selected-month" role="status">January</p>
Enter fullscreen mode Exit fullscreen mode

We can listen for the change in our dropdown and update the output:

document.getElementById('select-month').addEventListener('change', (event) => {
  document.getElementById('selected-month').innerText = event.target.value;
});
Enter fullscreen mode Exit fullscreen mode

Vue.js

In Vue, we can define a reactive variable to store the output:

const selectedMonth = ref('January');
Enter fullscreen mode Exit fullscreen mode

Now we can add a listener to the selector which updates the selectedMonth:

<form>
    <label for="select-month">Choose a month</label>
    <dropdown-selector @change="(event) => selectedMonth = event.target.value">
        <option>January</option>
        <option>February</option>
    </dropdown-selector>
</form>

<p role="status">@{{ selectedMonth }}</p>
Enter fullscreen mode Exit fullscreen mode

React

Unfortunately, the onChange listener that we would normally use in React doesn't fire when the dropdown selector
dispatches the event.

We can fix this by using useLayoutEffect to add a custom listener:

import {useLayoutEffect, useRef, useState} from 'react';

function App() {
  const [output, setOutput] = useState('January');

  const selectorRef = useRef();

  useLayoutEffect(() => {
    const {current} = selectorRef;

    current.addEventListener('change', (event) => {
        setOutput(event.target.value)
      }
    );
  });

  return (
    <div>
      <label htmlFor="dropdown-selector">Pick a month</label>
      <dropdown-selector id="dropdown-selector"
                         ref={selectorRef}
      >
        <option>January</option>
        <option>February</option>
      </dropdown-selector>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Changing things up and taking control

So, getting the value from the dropdown when the user selects a new option is relatively straightforward. But what about
when we want to take control of it instead.

There's quite a few things that can change here:

  • The list of options - think about a pair of inputs where the options in the second one change depending on what you select in the first. An example of this is when picking a car manufacturer and then a model made by that manufacturer;
  • The label - depending on what is actually in the dropdown list, we might want to modify the label to better inform the user what the choice being offered represents;
  • Disabling the dropdown - again with the car manufacturer/model example, you might want the model selector disabled until the user has specified a manufacturer;
  • Setting which option is selected;
  • Restyling the dropdown - perhaps changing the border to indicate an error of some kind.

Disabling the dropdown

In native HTML, a form input is disabled by setting a boolean attribute:

<select disabled>
</select>
Enter fullscreen mode Exit fullscreen mode

The browser will then ignore clicks, and will also try to apply some styling that indicates a disabled state. You can
also target the disabled element with CSS to apply your own styles.

We want the same kind of behaviour for our dropdown:

<form>
    <label for="select-month">Choose a month</label>
    <dropdown-selector id="select-month" disabled>
        <option>January</option>
        <option>February</option>
    </dropdown-selector>
</form>
Enter fullscreen mode Exit fullscreen mode

We need to add a few lines of code to the DropdownSelector:

export class DropdownSelector extends HTMLElement {
  static get observedAttributes() {
    return ['disabled'];
  }

  // ...

  connectedCallback() {
    if (this.isConnected) {
      // ...

      // we need to store whether the user has defined a tabIndex for later use
      this.__userTabIndex = this.tabIndex; 

      // ...

      // click = mousedown + mouseup
      // browsers set focus on mousedown
      this.__combobox.addEventListener('mousedown', this.mousedown.bind(this));

      // ...
    }
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'disabled') {
      if (newValue !== null) {
        // prevent focus from keyboard navigation
        this.tabIndex = '-1';
      } else {
        // restore the original tabIndex as set by the user
        // if the user didn't set a tabIndex, this will remove the tabIndex
        this.tabIndex = this.__userTabIndex;
      }
    }
  }

  disconnectedCallback() {
    // ...

    this.__combobox.removeEventListener('mousedown', this.mousedown.bind(this));

    // ...
  }

  // ...

  click(event) {
    if (this.disabled) {
      return;
    }

    this.__open ? this.closeList() : this.openList();
  }

  // ...

  mousedown(event) {
    if (this.disabled) {
      // stops the element getting focus when clicked
      event.stopImmediatePropagation();
      event.preventDefault();
    }
  }

  // ...

  get disabled() {
    // boolean attributes have no value - they either exist or they don't
    return this.hasAttribute('disabled');
  }

  set disabled(newValue) {
    if (newValue) {
      // boolean attributes have no value - they either exist or they don't
      this.setAttribute('disabled', '');
    } else {
      this.removeAttribute('disabled');
    }
  }

  // ...

  get tabIndex() {
    return this.getAttribute('tabIndex');
  }

  set tabIndex(newValue) {
    if (newValue) {
      this.setAttribute('tabIndex', newValue);
    } else {
      this.removeAttribute('tabIndex');
    }
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Let's break this down:

// ...

static get observedAttributes() {
  return ['disabled'];
}

// ...

connectedCallback() {
  if (this.isConnected) {
    // ...

    // we need to store whether the user has defined a tabIndex for later use
    this.__userTabIndex = this.tabIndex;

    // ...
  }
}

// ...

attributeChangedCallback(name, oldValue, newValue) {
  if (name === 'disabled') {
    if (newValue !== null) {
      // prevent focus from keyboard navigation
      this.tabIndex = '-1';
    } else {
      // restore the original tabIndex as set by the user
      // if the user didn't set a tabIndex, this will remove the tabIndex
      this.tabIndex = this.__userTabIndex;
    }
  }
}

// ...
Enter fullscreen mode Exit fullscreen mode

This tells our component to listen for changes to the disabled attribute - and then tells it what to do when it does change.

We want to prevent the user from being able to focus this element, so we need to set the tabIndex to -1 to remove it from the tab order.
When the element is re-enabled, we need to restore the original tabIndex value, or remove it if the user didn't set it).

// ...

// click = mousedown + mouseup - browsers focus on mousedown
this.__combobox.addEventListener('mousedown', this.mousedown.bind(this));

// ...

this.__combobox.removeEventListener('mousedown', this.mousedown.bind(this));

// ...

click(event) {
  if (this.disabled) {
    return;
  }

  this.__open ? this.closeList() : this.openList();
}

// ...

mousedown(event) {
  if (this.disabled) {
    // stops the element getting focus when clicked
    event.stopImmediatePropagation();
    event.preventDefault();
  }
}

// ...
Enter fullscreen mode Exit fullscreen mode

When you click, the browser will also fire two other events: a mousedown and a mouseup.
This allows clicks to be interrupted/cancelled (when you press the mouse button down, then move it away from the element you were pointing at before releasing the mouse, nothing will happen).

While we want to open the list when clicking (or block it from being opened if the element is disabled), we also want to prevent the disabled element from ever getting focus.
Browsers set focus in reaction to a mousedown event, so we need to listen for it and stop the default behaviour as well as the immediate propagation on the event loop for this event.

Of course, we also clean up the event listener should the dropdown selector be removed from the document.

// ...

get disabled() {
  // boolean attributes have no value - they either exist or they don't
  return this.hasAttribute('disabled');
}

set disabled(newValue) {
  if (newValue) {
    // boolean attributes have no value - they either exist or they don't
    this.setAttribute('disabled', '');
  } else {
    this.removeAttribute('disabled');
  }
}

// ...

get tabIndex() {
  return this.getAttribute('tabIndex');
}

set tabIndex(newValue) {
  if (newValue) {
    this.setAttribute('tabIndex', newValue);
  } else {
    this.removeAttribute('tabIndex');
  }
}

// ...
Enter fullscreen mode Exit fullscreen mode

These getters and setters allow us to reflect the properties and attributes of our component.
This means that whenever the disabled attribute is changed from outside the component, the internal property is correct, and vice versa.

Vanilla

In vanilla JavaScript, we can disable or enable our dropdown in the same way as any other element:

// this will disable the element
document.getElementById('select-month').setAttribute('disabled', '');

// this will enable it again
document.getElementById('select-month').removeAttribute('enabled');
Enter fullscreen mode Exit fullscreen mode

If you want the dropdown to be disabled when the page loads, then set the attribute in the markup.
It can then be removed later using JavaScript.

<dropdown-selector id="select-month" disabled>
Enter fullscreen mode Exit fullscreen mode

Vue.js

With Vue, you can bind the disabled attribute to a reactive boolean value or prop:

<dropdown-selector :disabled="disableSelectMonth">
Enter fullscreen mode Exit fullscreen mode

Vue will handle the presence of the disabled attribute appropriately when disableSelectMonth is false.

React

On the other hand, doing this in React:

<dropdown-selector disabled={disableSelectMonth}>
Enter fullscreen mode Exit fullscreen mode

Will cause the HTML to render as:

<dropdown-selector id="select-month" disabled="false">
Enter fullscreen mode Exit fullscreen mode

The HTML standard for boolean attributes is that if the attribute exists (even as disabled="false" or disabled=""), then it is to be evaluated as true.
So, counterintuitively, an element marked as disabled="false" is disabled.

To prevent React outputting the incorrect markup:

<dropdown-selector id="select-month" disabled={disableSelectMonth ? '' : null}>
Enter fullscreen mode Exit fullscreen mode

There is a bug report for this on GitHub.

Reactive label

Back in Making Web Components accessible, we pulled in a copy of the user's label so we could properly associate it using ARIA attributes within our component and provide a good accessible experience.

But what if the user wants to change the label after we've rendered the component already?

This is where another powerful browser API comes into play - the MutationObserver. With this API, we can watch for changes in the DOM and react accordingly.

Because of the length of code to manage the labels in our component, I've separated it from the connectedCallback method since the post on accessible components, and put in some work to allow for more general use:

attachLabelForAria(labelledElements) {
  if (!this.id) {
    return;
  }

  this.__parentLabel = document.querySelector(`[for=${this.id}]`);
  if (this.__parentLabel) {
    this.__label = document.createElement('label');
    this.__label.setAttribute('id', 'label');
    this.__label.textContent = this.__parentLabel.textContent;

    this.shadowRoot.appendChild(this.__label);

    labelledElements.forEach((element) => {
      element.setAttribute('aria-labelledby', 'label');
    });

    this.__parentLabel.addEventListener('click', this.click.bind(this));

    const style = document.createElement('style');
    style.textContent = '#label { position: absolute; left: -1000px}';

    this.shadowRoot.appendChild(style);

    this.__labelObserver = new MutationObserver((changes) => {
      if (changes[0]?.target === this.__parentLabel) {
        this.__label.textContent = this.__parentLabel.textContent;
      }
    });
    this.__labelObserver.observe(this.__parentLabel, { childList: true });
  }
}
Enter fullscreen mode Exit fullscreen mode

The accessibility and styling concerns in this method are pretty much the same as in the other post, so I won't repeat myself here.

What we're interested in is picking up on changes to the label in the DOM and updating our label accordingly within our Shadow DOM:

this.__labelObserver = new MutationObserver((changes) => {
  if (changes[0]?.target === this.__parentLabel) {
    this.__label.textContent = this.__parentLabel.textContent;
  }
});
this.__labelObserver.observe(this.__parentLabel, { childList: true });
Enter fullscreen mode Exit fullscreen mode

Here, we create an observer which watches for changes on the outer DOM label.
Since all we're interested in is the text content, we only need to observe for changes to the immediate children of the label ({ childList: true }).

We do need to ensure we clean up the observer if the dropdown is removed from the DOM, so we add this to the disconnectedCallback method:

this.__labelObserver.disconnect();
Enter fullscreen mode Exit fullscreen mode

Vanilla

<form>
    <label id="label-for-select-month" for="select-month">Choose a month</label>
    <dropdown-selector id="select-month">
        <option>January</option>
        <option>February</option>
    </dropdown-selector>
</form>
Enter fullscreen mode Exit fullscreen mode
document.getElementById('label-for-select-month').innerText = "Pick a date";
Enter fullscreen mode Exit fullscreen mode

Vue.js

<form>
    <label for="select-month">@{{ labelForSelectMonth }}</label>
    <dropdown-selector id="select-month">
        <option>January</option>
        <option>February</option>
    </dropdown-selector>
</form>
Enter fullscreen mode Exit fullscreen mode

React

<form>
    <label for="select-month">{labelForSelectMonth}</label>
    <dropdown-selector id="select-month">
        <option>January</option>
        <option>February</option>
    </dropdown-selector>
</form>
Enter fullscreen mode Exit fullscreen mode

Reactive options

We've already used a MutationObserver on our label, and we can use another one to react to changes to the dropdown's collection of options.

Firstly, I've extracted the code that handles creating our component's listbox into its own method - it needs to be called when we first render the dropdown (from connectedCallback) and whenever the options change:

__extractOptions() {
  // reset the current state and remove existing options from our component
  // this will also remove any event listeners currently attached to each option
  this.__selectedIndex = 0;
  [...this.__listbox.children].forEach((element) => {
    element.remove();
  });

  // build from options that are in the dropdown-selector element in the DOM
  this.__options = [...this.querySelectorAll('option')].map((option, index) => {
    if (option.hasAttribute('selected')) {
      this.__selectedIndex = index;
    }

    const element = document.createElement('div');
    element.textContent = option.textContent;
    element.classList.add('option');
    element.setAttribute('id', `option-${index}`);
    element.setAttribute('role', 'option');
    element.setAttribute('aria-selected', 'false');
    if (option.hasAttribute('selected')) {
      element.setAttribute('aria-selected', 'true');
    }

    this.__listbox.appendChild(element);

    return {
      label: option.textContent,
      selected: option.hasAttribute('selected'),
      value: option.getAttribute('value') ?? option.textContent,
    }
  });

  if (this.__options[0]) {
    this.__combobox.textContent = this.__options[this.__selectedIndex].label
    this.__value = this.__options[this.__selectedIndex].value;
  }

  [...this.__listbox.children].forEach((element, index) => {
    element.addEventListener('click', (event) => {
      event.stopPropagation();
      this.select(index);
      this.click(event);
    });
    element.addEventListener('mousedown', this.__setIgnoreBlur.bind(this));
  });
}
Enter fullscreen mode Exit fullscreen mode

Now we just need to set up our MutationObserver:

connectedCallback() {
//...
  this.__optionsObserver = new MutationObserver((changes) => {
    this.__extractOptions();
  });
  this.__optionsObserver.observe(this, {childList: true});
//...
}

//...

disconnectedCallback() {
  //...

  this.__optionsObserver.disconnect();
}
Enter fullscreen mode Exit fullscreen mode

We're only interested in changes to direct children of the dropdown-select element, which is why were only observing the child list.

The wonderful thing about the MutationObserver is that it only checks for changes once each time the event loop ticks. We can make a lot of different changes to our options, and they will be applied all at once at the next tick.

Vanilla

We can modify the HTML inside the dropdown-select like so:

document.getElementById('select-month').innerHTML('<option>January</option><option>April</option><option>July</option><option>October</option>');
Enter fullscreen mode Exit fullscreen mode

Vue.js

We can do conditional rendering and even looping over a reactive or computed array:

<dropdown-selector id="dropdown-selector">
  <option v-if="extraMonth" value="-1">Last December</option>
  <option v-for="(month, index) in monthList" :value="month" :key="index">@{{ months[month] }}</option>
</dropdown-selector>
Enter fullscreen mode Exit fullscreen mode

React

Again, we can do conditional rendering and looping:

<dropdown-selector id="dropdown-selector">
  { extraMonth && <option value="-1">Last December</option> }
  { [...Array(numMonths).keys()].map((m) => (
      <option value={m} key={m}>{months[m]}</option>
  ))}
</dropdown-selector>
Enter fullscreen mode Exit fullscreen mode

Changing the selected option

Typically, we want the user to select an option through the dropdown. But what if the developer wants to change the selection programatically?

The native HTML Select lets you change the selection via the value property. We can add a getter and setter to our dropdown component to do the same:

get value() {
  return this.__value;
}

set value(newValue) {
  // value is always a string because it's taken from an attribute
  // newValue might be a number, though
  this.select(this.__options?.findIndex((option) => option.value == newValue));
}
Enter fullscreen mode Exit fullscreen mode

Vanilla

document.getElementById('select-month').value('January');
Enter fullscreen mode Exit fullscreen mode

Vue.js

<dropdown-selector :value="selectedValue">
    <option>January</option>
</dropdown-selector>
Enter fullscreen mode Exit fullscreen mode

Or if we want two-way binding:

<dropdown-selector v-model="selectedValue">
    <option>January</option>
</dropdown-selector>
Enter fullscreen mode Exit fullscreen mode

React

To allow React to set the value from a reactive property, we need to tweak the component a little:

static get observedAttributes() {
  return ['disabled', 'value'];
}

//...

attributeChangedCallback(name, oldValue, newValue) {
  //...

  if (name === 'value') {
    this.value = newValue;
  }
}
Enter fullscreen mode Exit fullscreen mode

Reacting to changes in the stylesheet

This post is getting to be quite long, and properly handling changes to styles and the stylesheet is going to be quite involved. I'm not sure when I'll follow up on it, either, I'm afraid.

So what next?

I've really been enjoying this dive into Web Components, pushing at their boundaries and experimenting with improving their accessibility and stylability, while also keeping a good developer experience.

For now, I'm going to work on refining these concepts and start building an open source library of Accessible Web Components, but I hope to learn more and write further posts as I go along.

Oldest comments (0)