Making Web Components reactive
April 12, 2022
Series: Working with Web Components
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);
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>
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)
}
Or, you can use a prefix:
// vite.config.js or vue.config.js
compilerOptions: {
// list all custom-elements
isCustomElement: (tag) => tag.startsWith('awesome-')
}
This would require that you change the custom element definition:
customElements.define('awesome-dropdown-selector', DropdownSelector);
<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>
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')
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>
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')
)
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')
);
}
}
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>
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;
});
Vue.js
In Vue, we can define a reactive variable to store the output:
const selectedMonth = ref('January');
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>
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>
)
}
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>
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>
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');
}
}
// ...
}
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;
}
}
}
// ...
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();
}
}
// ...
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');
}
}
// ...
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');
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>
Vue.js
With Vue, you can bind the disabled attribute to a reactive boolean value or prop:
<dropdown-selector :disabled="disableSelectMonth">
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}>
Will cause the HTML to render as:
<dropdown-selector id="select-month" disabled="false">
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}>
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 });
}
}
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 });
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();
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>
document.getElementById('label-for-select-month').innerText = "Pick a date";
Vue.js
<form>
<label for="select-month">{{ labelForSelectMonth }}</label>
<dropdown-selector id="select-month">
<option>January</option>
<option>February</option>
</dropdown-selector>
</form>
React
<form>
<label for="select-month">{labelForSelectMonth}</label>
<dropdown-selector id="select-month">
<option>January</option>
<option>February</option>
</dropdown-selector>
</form>
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));
});
}
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();
}
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>');
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>
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>
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));
}
Vanilla
document.getElementById('select-month').value('January');
Vue.js
<dropdown-selector :value="selectedValue">
<option>January</option>
</dropdown-selector>
Or if we want two-way binding:
<dropdown-selector v-model="selectedValue">
<option>January</option>
</dropdown-selector>
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;
}
}
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.