What does it mean that Solid.js is truly reactive?
Nick Scialli
May 18, 2022
A question I have been hearing a lot recently is about Solid’s reactivity. Specifically, what makes a framework like Solid more reactive than, well, React.
The answer is the pub/sub model employed by Solid’s signals. Using a pub/sub model, we’re able to write the following code:
const [count, setCount] = createSignal(0);
setTimeout(() => {
setCount(count() + 1);
}, 1000);
createEffect(() => {
console.log(count());
});
And we will log the count every time it’s updated (every second). You’ll notice that we don’t have to enumerate our effect’s dependencies—it automatically knows that it should be triggered when count
is updated.
How it works
To help understand how reactivity works, we can actually build our own createSignal
and createEffect
primitives. This will both give us some insight into reactivity and “pull back the curtain” on some perceived framework magic.
To start out with, let’s make createSignal
. Based on how we use it, we know it will take an initial value as an argument and return a two-element tuple—a getter and a setter:
function createSignal(initialValue) {
let value = initialValue;
function getter() {
return value;
}
function setter(newValue) {
value = newValue;
}
return [getter, setter];
}
To reiterate, we have a createSignal
function that takes an initialValue
for our signal. It returns a two-element array: the first element is getter
, which gets the value
, and the second element is setter
, which sets the value
to newValue
.
Next, we need to set up our effect. The trick with effects in this pub/sub model is that createEffect
will put the function it calls in the global scope so that the signal can capture it as a listener. That’s a lot to grok, so let me show you what this looks like:
let listener;
function createSignal(initialValue) {
let value = initialValue;
const listeners = [];
function getter() {
if (listener) {
listeners.push(listener);
}
return value;
}
function setter(newValue) {
value = newValue;
}
return [getter, setter];
}
function createEffect(func) {
listener = func;
func();
listener = undefined;
}
So let’s think about what happens when we call the following:
createEffect(() => {
console.log(count());
});
- We have a global
listener
variable that gets assigned the entire function insidecreateEffect
- That function is actually executed
- When that function is executed, the getter for
count
is called, which adds thelistener
to the signal’s locallisteners
array - We set the global listener back to
undefined
After our createEffect
has been called, any signal called during the initial execution will have captured the effect function as a listener!
The final step is that each signal needs to trigger all of its listeners whenever its value changes. The can be done by looping over the listeners
array in the setter
function:
let listener;
function createSignal(initialValue) {
let value = initialValue;
const listeners = [];
function getter() {
if (listener) {
listeners.push(listener);
}
return value;
}
function setter(newValue) {
value = newValue;
listeners.forEach((listener) => {
listener();
});
}
return [getter, setter];
}
function createEffect(func) {
listener = func;
func();
listener = undefined;
}
And that makes for a quick and dirty reactive model! We can now see that the initial code we wrote logs the value of count
whenever it changes (every second).
const [count, setCount] = createSignal(0);
setTimeout(() => {
setCount(count() + 1);
}, 1000);
createEffect(() => {
console.log(count());
});
Nick Scialli is a senior UI engineer at Microsoft.