Signals for Reactivity

Having been roped into dealing with the excesses of RxJS lately, I’d been wondering about Angular folks’ excitement about signals. So I finally sat down in an effort to understand the furor.

While I didn’t entirely grok Preact’s explanation the first time around (after all, I typically try to minimize client-side state), I knew their signals library is both tiny and framework-agnostic – providing a suitable foundation for a minimal test case.

I went with the now stereotypical example of a counter and added two types of historical visualization: A basic log recording current and past values as well as a histogram-style chart of the respective trend (up or down).

Using signals means we need to wrap any values which, upon changing, should result in something happening elsewhere. Here everything revolves around that one numeric value:

import { signal } from "@preact/signals-core";

let INITIAL_VALUE = randomInt(1, 100);

let counter = signal(INITIAL_VALUE);

// returns a random integer within the given bounds (both inclusive)
function randomInt(min, max) {
    return Math.floor(Math.random() * (max - min + 1)) + min;
}

For simplicity, let’s automate changes instead of creating an interactive UI:

let FREQUENCY = 1000;

setInterval(() => {
    counter.value = randomInt(1, 100);
}, FREQUENCY);

As you can see, in order to access our numeric value, we need to use the eponymous property of our wrapper object. That allows the underlying library to detect changes so it can automatically notify whoever is interested in this value.1

Of course, nobody actually seems to be interested. Let’s rectify that:

import { effect } from "@preact/signals-core";

effect(() => {
    let timestamp = new Date().toISOString().slice(11, 19);
    console.log(`[${timestamp}] counter is at ${counter.value}`);
});

Whenever the counter changes, we emit its latest value on the console.

By virtue of reading counter.value and wrapping corresponding operations in an effect callback, we enable the library to mark this function as dependent on our signal. Whenever a signal’s value changes, dependent callback functions are notified – meaning they are invoked once more.

This is the equivalent of notifying subscribers in an event-based system, except it turns the whole thing on its head by automatically determining who subscribes to what. I suspect this can be both wonderful and infuriating: Relationships here are rather more implicit than events’ “if $event then $effect” subscriptions2, possibly complicating analysis and debugging. More subtly, the use of signals might conceivably result in performance issues as people liberally access signals’ values without considering the ramifications under the hood.

On the value-producing side, instead of assigning a random integer with each iteration, we might want to increment or decrement our counter:

setInterval(() => {
    let x = counter.value;
    counter.value = Math.random() < 0.5 ? x - 1 : x + 1;
}, FREQUENCY);

Even though we’re reading counter.value here, because we’re not using effect this function is not marked as dependent on the signal. In fact, it’d be a little more efficient, and arguably more correct and explicit, to employ .peek() in this instance: let x = counter.peek();

We were promised visualizations earlier, so we should at least replace console logging with DOM elements:

import { effect } from "@preact/signals-core";

let log = document.createElement("ol");
document.body.appendChild(log);

effect(() => {
    let timestamp = new Date().toISOString().slice(11, 19);
    let el = document.createElement("li");
    el.textContent = `[${timestamp}] counter is at ${counter.value}`;
    log.prepend(el);
});

We also wanted to get an idea of whether values are rising or falling; we can express that via an additional signal:

import { signal } from "@preact/signals-core";

let FREQUENCY = 1000;
let INITIAL_VALUE = randomInt(1, 100);

let counter = signal(INITIAL_VALUE);
let trend = signal(null);

setInterval(() => {
    let x = counter.peek();
    if(Math.random() < 0.5) {
        counter.value = x - 1;
        trend.value = "📉";
    } else {
        counter.value = x + 1;
        trend.value = "📈";
    }
}, FREQUENCY);

… which we then use for a simplistic histogram:

let chart = document.createElement("output");
document.body.appendChild(chart);

effect(() => {
    let { value } = trend;
    if(value) {
        chart.textContent += value;
    }
});

You’ll note that this only updates if the trend reverses rather than with every change. That’s due to a design decision by Preact Signals, which (quite reasonably) uses an equality check to ensure the respective value has actually changed before notifying subscribers. Thus trend.value = "📈" is effectively ignored if the value was the same before. In this case, we can work around that by assigning an object instead:

    if(Math.random() < 0.5) {
        // …
        trend.value = { symbol: "📉" };
    } else {
        // …
        trend.value = { symbol: "📈" };
    }
effect(() => {
    let { value } = trend;
    if(value) {
        chart.textContent += value.symbol;
    }
});

With this, we’re also being notified for recurring values (because it’s always a new object, so not the same value as far as JavaScript is concerned).

Our DOM log above barely counts as a visualization, so we might wanna add the trend in there. That means our log now depends on two distinct signals – which we might combine in a composite signal:

import { computed, signal } from "@preact/signals-core";

// …

let summary = computed(() => {
    let timestamp = new Date().toISOString().slice(11, 19);
    let symbol = trend.value?.symbol || "";
    return `${symbol} [${timestamp}] counter is at ${counter.value}`.trim();
});
effect(() => {
    let el = document.createElement("li");
    el.textContent = summary.value;
    log.prepend(el);
});

summary depends on both counter and trend, so changes to either will result in summary being recalculated, which is used by and thus updates our little DOM component. While it might be overkill in this minimal sample, distinguishing data model and GUI representation is generally good for separation of concerns.

We’re pretty much done here. There’s one final performance optimization we might consider for our value producer:

import { batch, computed, signal } from "@preact/signals-core";

// …

setInterval(() => {
    let x = counter.peek();
    let [count, symbol] = Math.random() < 0.5 ? [x - 1, "📉"] : [x + 1, "📈"];
    batch(() => {
        counter.value = count;
        trend.value = { symbol };
    });
}, FREQUENCY);

Wrapping multiple signal assignments in batch combines simultaneous notifications, rather than notifying subscribers multiple times in a row.

Overall, signals seem potentially useful and Preact Signals looks like an excellent implementation – not just for single-page applications, but also for progressively enhanced components within a regular web page. I’ll probably stick with vanilla DOM events for most cases though.