back

Declarative signals

December 30, 2024 – @hawkticehurst

A year ago I published an article that talked about my experience using HTML Web Components, and at the end introduced a tiny framework called Stellar that makes the job of authoring reactive HTML web components a bit easier.

Since then, the project has largely sat untouched, acting more as a research exercise or a demonstration of the ideas I had been mulling over in 2023.

As 2024 comes to a close I’ve been working on an evolution to Stellar (my habit of mulling clearly hasn’t changed). It’s still a work in progress, but I’ve stumbled upon an idea that I think is worth sharing now.

What would happen if we had a way of defining signals directly in HTML?

Signals, in JavaScript

I’d wager that most who are reading this are at least a little bit familiar with signals.

At their most basic, signals are a method of defining and managing a small piece of state that can be used to update a user interface in a declarative/reactive way.

For even more information, see Corbin Crutchley's fantastic post: What are Signals?.

Here’s a small example of using signals in vanilla JavaScript.

<button>Count is <span id="count">0</span></button>

<script type="module">
	// The state function is used to create a signal
	import { state } from './signal.js';
	// The effect function is used to define what should happen
	// when the value of a signal changes
	import { effect } from './effect.js';

	// Get references to the button and span elements
	const button = document.querySelector('button');
	const span = document.querySelector('#count');

	// Initialize count signal
	const count = state(0);

	// Update the value of the count signal on each button click
	button.addEventListener('click', () => {
		count.value = count.value + 1;
	});

	// Update <span> with the current value of the count signal
	// This runs anytime the value of the count signal is updated
	effect(() => {
		span.textContent = `${count.value}`;
	});
</script>

I expect that last bit of effect code might look a tiny bit weird to folks who are used to using signals in a web framework. In most frameworks the code that is responsible for updating the DOM after a signal has been changed is hidden from (read: automatically generated for) you and I.

Removing the responsbility for developers to write this kind of DOM updating code is a fantastic idea and is one of the core selling points of a declarative UI programming model.

There is, however, a catch that I want to focus on today. Declarative signals, as they exist now, are always packaged in a larger web framework or component model. But what if you are working with a technology stack that doesn’t natively support/provide signals?

You could adopt another framework into your stack that provides signals, but now you need to operate within the conventions of that framework. Or you could import a standalone signal library and write code similar to the example above, but now you’re back to handling DOM updating logic.

While completely workable, I find both of these options to be a bit unsatisfying, which brings me to my final question:

Wouldn’t it be great if there was an HTML element that handled reactive state management and rendering logic in a small package that could be used across any context and technology? What if we could write code that looked something like this?

<button>Count is <signal>0</signal></button>

<script>
	const button = document.querySelector('button');
	const count = document.querySelector('signal');
	button.addEventListener('click', () => {
		count.state = count.state + 1;
	});
</script>

Signals, as HTML

Obviously, there isn’t a <signal> HTML element available today, but it doesn’t mean we can’t make one ourselves!

To jump straight in, we’ll be building an <x-signal> custom element. Below is a very basic starter implementation of this custom element that will slowly be updated to include more features during the rest of this article.

import { Signal } from 'signal-polyfill';
import { effect } from './effect.js';

class SignalElement extends HTMLElement {
	constructor() {
		super();
		// Get initial value from the elements text content
		// Yes, this is weird -- I'll explain more in a bit
		const initial = this.textContent;
		// Create a new signal
		this.signal = new Signal.State(initial);
		// Everytime the signal value is updated rerender the element
		this.cleanup = effect(() => this.render());
	}
	connectedCallback() {
		// Render the element when it is first mounted to the DOM
		this.render();
	}
	disconnectedCallback() {
		// Run the effect cleanup when the element is destroyed
		this.cleanup();
	}
	render() {
		// Render the current value of the signal as the child
		// text content of the custom element
		this.textContent = this.signal.get();
	}
	// A getter method to access the current value of the signal
	get state() {
		return this.signal.get();
	}
	// A setter method to update the value of the signal
	set state(v) {
		this.signal.set(v);
	}
}

customElements.define('x-signal', SignalElement);

For the purposes of this demo we are using a polyfill of the JavaScript Signals proposal. The imported effect function is copied directly from the polyfill README.

The imported Signal class provides the core reactive primitive needed to make this all work and we are simply providing getter and setter methods named state to access and update the value of the signal.

For a great explainer on getters, setters, and signals see this video by Rich Harris.

Finally, the rest of the custom element handles all of the rendering logic. For now the element can only handle strings, but even this basic implementation is enough to create one of the classic hello worlds of reactive UI programming.

<label for="input">Enter name:</label>
<input id="input" type="text" value="World" />
<p>Hello <x-signal>World</x-signal>!</p>

<script>
	const input = document.querySelector('input');
	const name = document.querySelector('x-signal');
	input.addEventListener('input', () => {
		name.state = input.value;
	});
</script>

Hello World!

HTML-based state

At this point, some of you may have already started to notice and question: How/where is the value of these signals being initialized? To answer that we need to quickly talk through a key idea from Stellar that I worked on last year.

Like many other web frameworks, the current version of Stellar ships a reactivity model that lets you declaratively define pieces of stateful UI.

Unlike other web frameworks, however, state is defined a little different than what you might have experienced when using tools like React, Svelte, or Vue. In most frameworks, state is defined in JavaScript and then passed into component markup using some type of templating syntax. In Stellar, this relationship is inversed. Reactive state is defined in HTML and then made accessible for manipulation in JavaScript.

I call it “HTML-based state.”

In Stellar, this means a handful custom attributes called “directives” can be added to elements within a Stellar component to access the reactivity model.

A key part of all of this is that state is automatically initialized based on the existing text content, inner HTML, or property value of a stateful element.

As an example, the following Stellar code…

<p $state="text">Hello world!</p>

…should be interpreted in the following way:

A piece of code that is annotated to show the different parts of a Stellar reactive state declaration.

While the syntax for x-signal looks a bit different (i.e. there are no directives), HTML-based state is also a key idea of <x-signal>.

<p><x-signal>Hello world<x-signal></p>

<script>
	const text = document.querySelector('x-signal');
	console.log(text.state); // Logs "Hello world"
</script>

You are literally encoding the initial value of your signal into HTML itself.

Let’s do some coercion

Okay great, we can initialize a signal but we still have that pesky problem of text content being returned as a string in JavaScript.

In Stellar, all reactive state is coerced to the correct data type when being accessed in JavaScript. For example, if you were trying to define a count state initialized with the value 0 it will be coerced into type number. So let’s just do the same for <x-signal>.

We’ll also add the ability to set initial state via a state attribute which will be useful later when working with arrays and objects.

// NEW: Function for coercing a value to it's correct data type
function coerce(value) {
	if (!value) return;
	if (value === 'false' || value === 'true') {
		return value === 'true';
	}
	if (!isNaN(Number(value))) {
		return Number(value);
	}
	try {
		// Switch single quotes to double quotes, otherwise
		// JSON.parse will fail to parse what should be valid JSON
		const correctedValue = value.replace(/'/g, '"');
		const parsed = JSON.parse(correctedValue);
		if (Array.isArray(parsed)) return parsed;
		if (typeof parsed === 'object') return parsed;
	} catch (e) {
		// Not valid JSON, return the original value
	}
	return value;
}

class SignalElement extends HTMLElement {
	constructor() {
		super();
		// UPDATED: Coerce the text content of the element and
		// check for a state attribute
		const initial =
			coerce(this.getAttribute('state')) || coerce(this.textContent);
		this.signal = new Signal.State(initial);
		this.cleanup = effect(() => this.render());
	}
	render() {
		// UPDATED: Coerce signal to a string before rendering it
		this.textContent = `${this.signal.get()}`;
	}
	// ... other methods ...
}

And with those changes, we now can implement the next classic hello world of reactive UI programming.

<button>Count is <x-signal>0</x-signal></button>

<script>
	const button = document.querySelector('button');
	const count = document.querySelector('x-signal');
	button.addEventListener('click', () => {
		count.state = count.state + 1;
	});
</script>

Rendering HTML

Next on the list of useful features that are commonly needed for reactive UI programming is the ability to reactively update the inner HTML of an element instead of the text content.

class SignalElement extends HTMLElement {
	constructor() {
		super();
		// NEW: A flag for checking if the render attribute is set
		this.isHTML = this.getAttribute('render') === 'html';
		// UPDATED: Handle HTML rendering
		const initial = this.isHTML
			? coerce(this.getAttribute('state')) || this.innerHTML
			: coerce(this.getAttribute('state')) || coerce(this.textContent);
		// ... other code ...
	}
	render() {
		// UPDATED: Handle HTML rendering
		if (this.isHTML) {
			this.innerHTML = this.signal.get();
		} else {
			this.textContent = `${this.signal.get()}`;
		}
	}
	// ... other methods ...
}

And now we have the pieces needed to create a basic markdown editor/renderer with surprisingly little code.

<markdown-editor>
	<textarea># Hello markdown!</textarea>
	<x-signal render="html">
		<h1>Hello markdown!</h1>
	</x-signal>
</markdown-editor>

<script>
	import { marked } from 'https://unpkg.com/[email protected]/lib/marked.esm.js';
	const editor = document.querySelector('textarea');
	const output = document.querySelector('x-signal');
	editor.addEventListener('input', () => {
		output.state = marked(editor.value);
	});
</script>

This example includes a second custom element (markdown-editor), but no JavaScript definition for that element. In this case, we've created a CSS Web Component to wrap the text area and signal for the purpose of adding styles.

Hello markdown!

Rendering state in a custom way

So far we’ve seen examples of when the underlying state is perfectly reflected to the DOM. But what about when the underlying data/state is different than how that data/state is rendered?

We can add the ability to pass in a callback that will mutate how the data contained in a signal is rendered.

class SignalElement extends HTMLElement {
	constructor() {
		super();
		// UPDATED: Initialize mutation property and
		// add a default callback that does nothing
		this.mutation = (state) => state;
		// ... other code ...
	}
	render() {
		// UPDATED: Mutate state before rendering
		const value = this.mutation(this.signal.get());
		if (this.isHTML) {
			this.innerHTML = value;
		} else {
			this.textContent = `${value}`;
		}
	}
	// NEW: Add a method to set the custom mutation callback
	set customRenderer(callback) {
		this.mutation = callback;
		this.render();
	}
	// ... other methods ...
}

I personally don't like the property name customRenderer, but I couldn't think of something better. If you have better ideas please do suggest them!

Here’s an example of rendering text content in a custom way based on the values in an array passed to the state attribute we implemented earlier.

<p>
	<x-signal id="numbers" state="[1,2,3,4]">1 + 2 + 3 + 4</x-signal> =
	<x-signal id="sum">10</x-signal>
</p>
<button>Add number</button>

<script>
	const button = document.querySelector('button');
	const numbers = document.querySelector('#numbers');
	const sum = document.querySelector('#sum');
	numbers.customRenderer = (state) => {
		return state.join(' + ');
	};
	button.addEventListener('click', () => {
		numbers.state = [...numbers.state, numbers.state.length + 1];
		sum.state = numbers.state.reduce((s, c) => s + c, 0);
	});
</script>

1 + 2 + 3 + 4 = 10

Here’s an example of rendering inner HTML in a custom way to create a todo list.. or at least a partial implementation of one.

<label for="todo-input">Add todo:</label>
<div>
	<input id="todo-input" type="text" />
	<button>Add</button>
</div>
<x-signal state="['Get groceries']" render="html">
	<ul>
		<li>Get groceries</li>
	</ul>
</x-signal>

<script>
	const input = document.querySelector('input');
	const button = document.querySelector('button');
	const todos = document.querySelector('x-signal');
	todos.customRenderer = (state) => {
		return `<ul>
			${state.map((todo) => `<li>${todo}</li>`).join('')}
		</ul>`;
	};
	function addTodo() {
		if (input.value.length > 0) {
			todos.state = [...todos.state, input.value];
			input.value = '';
		}
	}
	input.addEventListener('keydown', (event) => {
		if (event.key === 'Enter') {
			addTodo();
		}
	});
	button.addEventListener('click', addTodo);
</script>
  • Get groceries

Derived state

So far we’ve only covered a very basic usage of signals, but what about derived/computed state?

We can use Signal.Computed provided by the signal polyfill to create a computed property.

class SignalElement extends HTMLElement {
	// ... other methods ...
	set computed(callback) {
		// Clean up existing signal
		this.cleanup();
		// Reset existing signal with a computed signal
		this.signal = new Signal.Computed(callback);
		// Redefine effect code for this new computed signal
		this.cleanup = effect(() => this.render());
	}
}

And now we can pass a callback to that property that will correctly derive new state based on the value of existing state.

<button>Count is <x-signal id="count">0</x-signal></button>
<p>Doubled: <x-signal id="doubled">0</x-signal></p>

<script>
	const button = document.querySelector('button');
	const count = document.querySelector('#count');
	const doubled = document.querySelector('#doubled');
	button.addEventListener('click', () => {
		count.state = count.state + 1;
	});
	doubled.computed = () => count.state * 2;
</script>

Doubled: 0

And with that, we’ve completed an implementation of <x-signal>!

What did we miss?

There’s certainly a fair few edge cases and questions that were missed in this article, but I’ll cover two of them now.

Reactive properties

In Stellar and other reactive frameworks you have the ability to define reactive properties. For example, the value property on a slider/range element. While I haven’t gotten around to implementing it, I believe it should be possible to wrap an HTML element with <x-signal> and reactively track a property of that element.

<x-signal track="value">
	<input type="range" value="1" />
<x-signal>

<script>
	const slider = document.querySelector("x-signal");
	console.log(slider.state) // Logs 1
	slider.state = 8;
	console.log(slider.state) // Logs 8
</script>

State that starts in the server

Another idea worth calling out is that while the examples in this article demonstrate handwriting state directly inside HTML elements, the real use case is to declare initial state in your server and pass the state into <x-signal> using the templating language of your server framework.

When used in a framework like Astro, initial element state can start in the server, be encoded directly into your HTML, and then be seamlessly hydrated on the client without any layout shift issues or flashes of new content once JavaScript is parsed and executed.

---
// Server data
const data = [1, 2, 3, 4];
---

<add-numbers>
	<p>
		<x-signal id="numbers" state={JSON.stringify(data)}>
			{data.join(' + ')}
		</x-signal> =
		<x-signal id="sum">
			{data.reduce((s, c) => s + c, 0)}
		</x-signal>
	</p>
	<button>Add number</button>
</add-numbers>

<script>
	import './signal-element.js';
	// Add numbers client code
</script>

<style>
	/* Styles */
</style>

Islets architecture

To close out, I’ll end this article with one more thought.

As I’ve discussed a few times before, custom elements are the perfect technology for implementing the islands architecture. Each custom element becomes it’s own island of interactivity within a larger sea of static HTML.

With <x-signal> I think we’ve stumbled upon an even lower level to this idea. An island that contains and reactively renders a single unit of data, such as a number, a string, an array, a snippet of HTML, and so on.

In a sea of static HTML and islands, we’ve discovered a tiny island… an islet.


If you want to see the full implementation of <x-signal> or try it for yourself, I’ve uploaded a self-contained implementation to the vnext branch of the Stellar repo. If you run into any questions or bugs feel free to open an issue or [at] me on Bluesky or Mastodon.