Declarative and component-based UI with Signals core

Frameworks like Alpine and Stimulus continue to enjoy a widespread appeal. I'm a big fan too.

Their declarative and component-based approach to UI, which is also characteristic of React, Vue.js, Svelte, etc., is enjoyable and offers easy-to-understand patterns.

<div x-data="{ count: 0 }">
<button x-on:click="count++">Increment</button>
 
<span x-text="count"></span>
</div>

However, there are instances where the HTML cannot be "decorated" with attributes of this kind. Or perhaps you dislike "polluting" the DOM or prefer less magic and to stay closer to barebones JavaScript.

If that's the case, but you still want state-driven reactive UIs, using Signals at the core of your components might be the solution.

Signals

@preact/signals-core is a good choice for state management and to be the driving force behind the UI and DOM updates. Using the signal and effect functions provides you with the low-level API necessary for creating reactive components.

import { signal, effect } from '@preact/signals-core';
 
const name = signal('Jane');
 
// Logs name every time it changes:
effect(() => console.log(name.value));
// Logs: "Jane"
 
// Updating `name` triggers the effect again:
name.value = 'John';
// Logs: "John"

This simplicity is deceptively powerful.

If you are familiar with any of the previously mentioned frameworks, the following examples will feel familiar.

Compared to Alpine

The chosen components are from the Alpine getting started page, where they build the same UI elements "their way". This way, you can compare the two easily.

Without further due, here's the counter:

<div class="counter">
<button type="button">Increment</button>
<span></span>
</div>
function counter(rootElement) {
const count = signal(0);
 
const displayCount = () => {
rootElement.querySelector('span').innerHTML = count.value;
};
 
const handleIncrementCount = () => {
count.value = count.value + 1;
};
 
const init = () => {
effect(displayCount);
 
rootElement
.querySelector('button')
.addEventListener('click', handleIncrementCount);
};
 
return {
init,
};
}
 
counter(document.querySelector('.counter')).init();

Because we used a signal in the displayCount, all we had to do is to "wrap" the displayCount in an effect.

As the documentation says:

To run arbitrary code in response to signal changes, we can use effect(fn). [...], effects track which signals are accessed and re-run their callback when those signals change.

You are not alone if this reminds you of some sorts of proxy state. It's a bit like that but technically very different.

You can also play with it on CodePen.

This would be the dropdown:

<div class="dropdown">
<button type="button">Toggle</button>
<div>Contents...</div>
</div>
function dropdown(rootElement) {
const open = signal(false);
 
const displayContent = () => {
rootElement.querySelector('div').style.display = open.value
? ''
: 'none';
};
 
const handleClickOutside = (event) => {
if (rootElement.contains(event.target)) {
return;
}
 
open.value = false;
};
 
const handleToggleOpen = () => {
open.value = !open.value;
};
 
const init = () => {
effect(displayContent);
 
rootElement
.querySelector('button')
.addEventListener('click', handleToggleOpen);
 
document.addEventListener('click', handleClickOutside);
};
 
return {
init,
};
}

Last but not least, the search input:

<div class="search">
<input type="search" placeholder="Search...">
<ul>
</ul>
</div>
function search(rootElement) {
const items = ['foo', 'bar', 'baz'];
const search = signal('');
const matchedItems = computed(() =>
items.filter((item) => item.startsWith(search.value)),
);
 
const displayResults = () => {
rootElement.querySelector('ul').innerHTML = matchedItems.value
.map((item) => `<li>${item}</li>`)
.join('');
};
 
const handleQueryChange = (event) => {
search.value = event.target.value;
};
 
const init = () => {
effect(displayResults);
 
rootElement
.querySelector('input')
.addEventListener('keyup', handleQueryChange);
};
 
return {
init,
};
}

Of course, these are all naive implementations and are not handling cases where things could go wrong, but they should demonstrate how things could be structured and glued together.


For more complex situations, you can consider @deepsignal which extends Signals. It allows the state to be written in the following way:

import { deepSignal } from '@deepsignal/preact';
 
const userStore = deepSignal({
name: {
first: 'Thor',
last: 'Odinson',
},
email: 'thor@avengers.org',
});

The WordPress Interactivity API builds both on Signals and DeepSignal (and Preact).

It's the first time WordPress has tried to offer some standardization JavaScript used on the front-end.