Lately, the frontend space has been exploding with a newfound admiration for fine-grained reactivity, a style of building reactive user interfaces by using three primary primitives: signals, effects, and memos. Recently, we’ve seen frameworks like Angular, Preact, and Qwik add signals to their existing frameworks. And of course, SolidJS, my framework of choice, and creator Ryan Carniato, have led the recent popularity in signals for frontend frameworks.
What is a signal?
The name “signal” isn’t particularly descriptive, so it can be a bit confusing at first as to what benefits they offer, and how they differ from things like RxJS Observables. SolidJS offers a solid (heh) definition:
Signals are event emitters that hold a list of subscriptions. They notify their subscribers whenever their value changes.
— SolidJS Docs
Don’t worry if that doesn’t make complete sense — we’re going to learn by writing our own!
Why create your own signals?
If all of these libraries already offer signal primitives, why should we write our own? Well, in truth, it’s mainly as a learning experience! I wouldn’t necessarily advise using what we write over a framework like Solid, which contains more advanced optimizations for different edge cases. Rather, this practice is going to help you better understand how Solid, and reactivity in general, works.
The Basics
For our basic reactive system, we’re going to create two primitives: createSignal
and createEffect
. There is a third important primitive to reactivity, the memo (aka createMemo
), but it’s not necessary for our basic demo.
createSignal
will be used to read and set a reactive value, and createEffect
will be used to run side effects whenever that value changes.
Let’s go ahead and create the shell of createSignal
in JavaScript:
function createSignal(initialValue) {
let value = initialValue
const read = () => value
const write = (newValue) => (value = newValue)
return [read, write]
}
// example usage
const [count, setCount] = createSignal(0)
To break this down:
- We save our initial signal value in the
value
variable. - We create an accessor function,
read
, which just returns the current value of the signal in a closure. - We create a setter function, which takes a new value and replaces the existing one.
- We return a tuple with the two functions
So far, this isn’t any more useful than just declaring a mutable variable with let
. What’s “reactive” about this? Well, it’s not! By themselves, signals are no more than glorified variables. It’s only when we start using other primitives, like effects, that we see their real power.
Remember, a signal holds “a list of subscriptions.” Those subscriptions come from createEffect
, which takes a callback function as an input and sets it as a global listener, to be read by signals. Let’s take a look:
let currentListener = undefined
function createEffect(callback) {
currentListener = callback
callback()
currentListener = undefined
}
// example usage
createEffect(() => {
console.log(someSignal())
})
Here, we set currentListener
to the effect callback, and then we call the callback function. This is where the magic happens — by calling the callback, any signals used in that callback will be called as well. Next, we need to go back to our createSignal
function and tweak it to add currentListener
to a list of subscribers, and call any subscribers on change:
function createSignal(initialValue) {
let value = initialValue
// a set of callback functions, from createEffect
const subscribers = new Set()
const read = () => {
if (currentListener !== undefined) {
// before returning, track the current listener
subscribers.add(currentListener)
}
return value
}
const write = (newValue) => {
value = newValue
// after setting the value, run any subscriber, aka effect, functions
subscribers.forEach((fn) => fn())
}
return [read, write]
}
Now, by tracking the subscribers in their own closure, any usage of a signal inside an effect automatically tracks it, and re-runs that effect anytime the value changes!
Believe it or not, that’s it. There are a lot of ways this code can be improved and optimized for the end user, but it IS reactive, and can be used right away! Here’s a complete snippet for your usage:
let currentListener = undefined
function createSignal(initialValue) {
let value = initialValue
const subscribers = new Set()
const read = () => {
if (currentListener !== undefined) {
subscribers.add(currentListener)
}
return value
}
const write = (newValue) => {
value = newValue
subscribers.forEach((fn) => fn())
}
return [read, write]
}
function createEffect(callback) {
currentListener = callback
callback()
currentListener = undefined
}
Using your Signals
Now that we’ve created basic reactive primitives, let’s put them to use! We can create a simple counter using our signals, all in plain JavaScript:
const [count, setCount] = createSignal(0)
const button = document.createElement('button')
createEffect(() => {
button.innerText = count()
})
button.addEventListener('click', () => {
setCount(count() + 1)
})
document.body.append(button)
https://codepen.io/lukeshafer/pen/MWqJaQw
In this example, instead of writing to button.innerText
directly, we update our count signal using setCount()
. Then we use an effect to set button.innerText
to the count
signal’s value.
Now, instead of tying the action of clicking to the visible count value, we get to track the count as its own value. This means other actions or functions could update count
, or use it’s value, and the value will always synchronize in the DOM. This save you the trouble of needing to remember every place you used it, and it offers very little runtime overhead on top of the typical vanilla method, especially compared to a virtual DOM.
One issue with this code, though, is it’s still pretty verbose. On top of the already wordy API for updating the DOM imperatively, we now need to wrap any DOM interactions that use signals with effects, leading to a lot of repeated boilerplate. It’s fine for smaller, simpler interactivity, but as soon as you’re working with multiple signals in multiple DOM locations, you’re going to get wordy very quickly.
Now, let’s re-introduce SolidJS, and how it takes a few simple rules to make a powerful framework. Let’s see what our above code looks like in Solid:
export function Counter() {
const [count, setCount] = createSignal(0);
return <button onClick={() => setCount(count() + 1)}>{count()}</button>
}
// with comments describing how it maps to our previous code
export function Counter() {
// everything before we start updating the DOM is the same
const [count, setCount] = createSignal(0);
// const button = document.createElement("button")
return <button
{/* button.addEventListener("click", () => {
* setCount(count() + 1);
* });*/}
onClick={() => setCount(count() + 1)}>
{/* createEffect(() => {
* button.innerText = count();
* });*/}
{count()}
// document.body.append(button)
</button>
}
As you can see, any call to a signal in the JSX results in that expression being wrapped in a createEffect
automatically. This specific behavior is the main reason Solid uses a compiler. This means that any usage of a signal in JSX is automatically reactive, and the DOM will update on its own when that signal is updated.
Of note — the code we wrote isn’t exactly what the Solid compiler outputs, as it uses template elements and a few other optimizations to lead to the best possible performance. Still, the compiler’s output is still readable, and I highly recommend messing with the Solid playground to get a better feel for how it transforms your code, as it’s very valuable to understand what you’re shipping to your users!