Signals for Reactivity
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.