Skip to main content Accessibility Feedback

Reactive Web Components and DOM diffing

This week, I had a conversation on Mastodon with Kyle Leaders and Jake Lazaroff about reactivity and DOM diffing in Web Components

I have to say I’ve been actually enjoying working with #webcomponents in #vanillajs lately. I haven’t attempted anything too complicated, but I love the idea of reusable components. Next up, I need to figure out how to best do reactive updates.

This is one area where the native web platform falls a bit short.

What can Web Components do out-of-the-box?

Web Components have built-in reactivity with the attributeChangedCallback() method. It detects when registered attributes on the Web Component change, and can run code in response.

// Runs when the value of an attribute is changed on the component
attributeChangedCallback (name, oldValue, newValue) {
	updateTheGreeting(newValue);
}

// Create a list of attributes to observe
static get observedAttributes () {
	return ['greeting'];
}

That’s great for small, encapsulated data, but doesn’t work so well for bigger data sets (like arrays and objects) or data that’s shared across components.

// A large, shared data set
// Web Components can't natively react to this
let data = {
    heading: 'My Todos',
    todos: ['Swim', 'Climb', 'Jump', 'Play'],
    emoji: 'πŸ‘‹πŸŽ‰'
};

What can you cobble together with a little work

You can (with a little bit of work) hack together reactive data using Proxies or setter functions.

// This creates a Proxy
// A "signal:updated" event gets emitted when data in it changes
let data = signal({
    heading: 'My Todos',
    todos: ['Swim', 'Climb', 'Jump', 'Play'],
    emoji: 'πŸ‘‹πŸŽ‰'
});

But when its time to update the UI, you’re still stuck either completely wiping out the existing DOM, or doing manual DOM manipulation.

customElements.define('todo-list', class extends HTMLElement {

	constructor () {

		super();
		this.innerHTML = this.template();

		// This completely wipes out and repaints the DOM
		// That both inefficient and can create A11Y problems
		document.addEventListener('signal:updated', () => {
			this.innerHTML = this.template();
		});

	}

	template () {
	    let {heading, todos, emoji} = data;
	    return `
	        <h1>${heading} ${emoji}</h1>
	        <ul>
	            ${todos.map(function (todo) {
	                return `<li key="${todo}">${todo}</li>`;
	            }).join('')}
	        </ul>`;
	}

}

We need a native diff() method that will compare an existing element to an HTML string, and match them up in the least obtrusive way possible.

We could use a reactive Web Component class

When I started writing this, I had gone down the path of creating a new ReefElement class for Reef, my state-based UI library, that could be used to create reactive Web Components.

import {signal, ReefElement} from './reef.es.js';

// Create a reactive signal
let data = signal({
    heading: 'My Todos',
    todos: ['Swim', 'Climb', 'Jump', 'Play'],
    emoji: 'πŸ‘‹πŸŽ‰'
});

// Create a reactive component
// This automatically renders whenever `data` changes
customElements.define('todo-list', class extends ReefElement {
	template () {
		let {heading, todos, emoji} = data;
		return `
			<h1>${heading} ${emoji}</h1>
			<ul>
				${todos.map(function (todo) {
					return `<li key="${todo}">${todo}</li>`;
				}).join('')}
			</ul>`;
	}
});

I got to the point of creating a fully working version and a bunch of demos. It adds just 600 bytes (0.6kb) to Reef.

But the approach doesn’t quite site right with me.

First, it’s too clever. In my opinion, it does a little too much “automagically” under-the-hood and hides how the sausage is made.

And perhaps worst-of-all, it imposes vendor lock-in. All libraries do to some extent, but by using a library-specific base-class, migrating away when the web platform catches up becomes a lot harder.

That’s the antithesis of how I like to build things.

Native Web Component reactivity with Reef

You know how sometimes if you stare at a problem for too long, you miss the obvious, more simple solution? Then you come back to it the next day it smacks you in the face?

Yea, that happened to me! You can use Reef right out-of-the-box to create reactive native Web Components already.

import {signal, component} from './reef.es.js';

// Create a reactive signal
let data = signal({
	heading: 'My Todos',
	todos: ['Swim', 'Climb', 'Jump', 'Play'],
	emoji: 'πŸ‘‹πŸŽ‰'
});

// Create a native Web Component
customElements.define('todo-list', class extends HTMLElement {

	// Use Reef to reactively render UI into the Web Component
	constructor () {
		super();
		component(this, this.template);
	}

	// Define the template
	template () {
		let {heading, todos, emoji} = data;
		return `
			<h1>${heading} ${emoji}</h1>
			<ul>
				${todos.map(function (todo) {
					return `<li key="${todo}">${todo}</li>`;
				}).join('')}
			</ul>`;
	}

});

It’s a small shift, but it means that your finished code is much more native to start with.

Reef’s component() method listens for state changes, and diffs the DOM inside the Web Component whenever your reactive data changes.

Some advantages of combining Reef and Web Components

Pairing Reef with Web Components provides some great developer ergonomics.

First, you don’t need to add unique selectors to things when you setup your reactive component() objects. In the Web Components constructor() function, this is already scoped to your custom element, and provides encapsulation for all of your methods.

The built-in encapsulation that Web Components provide means you can also do things like tie state to the element instead of (or in addition to) having global state.

Imagine having a handful of <count-up> buttons.

<count-up id="btn1"></count-up>
<count-up id="btn2"></count-up>
<count-up id="btn3"></count-up>

Each one renders a button that keeps track of (and displays) how many times it’s been clicked.

<count-up>
	<button>Clicked 42 times</button>
</count-up>

Without a Web Component, you would need three different signal() objects, each with a unique value. And you would need to create three different component() objects.

let count1 = signal(0);
let count2 = signal(0);
let count3 = signal(0);

function countUp1 () {
	count1.value++;
}

function countUp2 () {
	count1.value++;
}

function countUp3 () {
	count1.value++;
}

function template1 () {
	return `<button onclick="countUp1()">Clicked ${count1.value} times</button>`;
}

function template2 () {
	return `<button onclick="countUp2()">Clicked ${count2.value} times</button>`;
}

function template3 () {
	return `<button onclick="countUp3()">Clicked ${count3.value} times</button>`;
}

component('#btn1', template1, {events: countUp1});
component('#btn2', template2, {events: countUp1});
component('#btn3', template3, {events: countUp1});

That’s a lot of repeated code! You can certainly abstract some of it, but now you’re in the territory of writing “clever” code that takes a bit longer to understand.

With native Web Components and Reef, you can do this instead…

// The component
customElements.define('count-up', class extends HTMLElement {

	constructor () {
		super();
		this.count = signal(0);
		this.events = {countUp: () => this.count.value++};
		component(this, this.template, {events: this.events});
	}

	template = () => {
		return `<button onclick="countUp()">Clicked ${this.count.value} times</button>`;
	}

});

Since your signal(), events, and template() are all scoped to the component, authoring becomes much more simple.

Reef also automatically ignores Web Components in templates when diffing, so you can nest multiple reactive components and each one will handle itself without interfering with the other.

When should you do something like this?

As I mentioned yesterday, I really prefer HTML Web Components. These are Web Components that start with native HTML and progressively enhance what’s already there.

But sometimes, you need state-based UI. In those situations, I think combining Reef with what Web Components already off out-of-the-box is the best of both worlds.