How Signals in JavaScript work? Let’s build one and find out!
Signals in JavaScript, imagined by DALL·E
It’s been over a decade since React was released, and over 5 years since the React team introduced functional components, marking a huge paradigm shift. In fact, they not only took over the class components, but inspired a lot of libraries, like SolidJS.
There are tons of articles explaining how React functional components work, and it’s quite easy to get it. Today, we will focus solely on its state and effects hooks, as an entry point to understanding Signals. Let’s look at the example below:
const SimpleComponent = () => {
const [count, setCount] = useState(0); // state value set to 0
useEffect(() => {
const intervalId = setInterval(
() => setCount(prev => prev + 1),
1000
);
return () => clearInterval(intervalId)
}, [])
useEffect(() => {
console.log('Count has changed!', count);
}, [count])
// ...
}
/*
Count has changed! 0
Count has changed! 1 // after a second
Count has changed! 2 // after another second
...and so on...
*/
It’s clear that we maintain a state calledcount
that may be updated using the setCount
method. We have two useEffect
hooks here — one initializes the setInterval
to increment count
by 1 every second, and the other one logs count
value whenever it changes. Both hooks execute in the first component render. Then after a second, state changes, and React re-renders the component, executing all the component code one more time with the new state. This is why it’s crucial for the useEffect
to maintain the dependency array [count]
, to understand when to re-execute by comparing the old value to the new one. No magic, all straight to the point.
And this paradigm is the main reason why I was so confused when I first saw the Signals, where effects are magically re-executing automatically when the values they depend on change. With no dependency array.
Signals
Let’s jump to another example and look at the similar example with signals:
const [count, setCount] = signal(0); // state value set to 0
setInterval(
() => setCount(prev => prev + 1),
1000
);
effect(() => {
console.log('Count has changed!', count());
})
/*
Count has changed! 0
Count has changed! 1 // after a second
Count has changed! 2 // after another second
...and so on...
*/
Notice any differences beyond the change in naming? I bet you do. Let’s break it down:
- There’s no component defined here because Signals are not necessarily part of the component lifecycle, though they should be in practice to prevent memory leaks and other issues.
count
is now a function, not a variable that stores the state value.- Effect function has no dependency array, and yet still works as expected!
Key to understanding how it works is in the count
function and single-threaded nature of JavaScript. Let’s see how we can implement the basic signal
function first:
const signal = (value) => {
let _value = value
function read() {
return _value
}
function write(value) {
_value = value
}
return [read, write]
}
Alright, you might say, that was easy. We store a mutable value inside the signal
function and interact with it using read
and write
functions. Makes sense. But where is the magic with auto-tracking effect dependencies? Well, let’s define the basic effect
function:
const effect = (cb) => {
let _externalCleanup // defined explicitly by user
function execute() {
dispose()
_externalCleanup = cb()
}
function dispose() {
if (typeof _externalCleanup === "function") {
_externalCleanup()
}
}
execute()
return dispose
}
Okay, we have an effect, that for some reason is a bit more complicated than it could be, but still clear, right? It takes the callback, that may or may not return some explicit clean up function (similar to React’s useEffect
), but where’s the magic?
Magic behind the Signals
Let’s pause here for a while and think. JavaScript is single-threaded language, and what it means is that it can only run a single operation at any given time in the Main Thread. There are many helpful videos on YouTube that explain that concept really well, and how asynchronous code is handled in such an environment. But for now, let’s just take it as a fact and see what happens in the example code above:
- We define a signal with initial value set to 0
- We run an effect using its
execute
function, that reads our signal’s value by callingcount
function - When we call
count
, effect is still running, this is important - Our call stack will look something like this:
effect -> execute -> dispose -> cb -> count -> console.log
- Effect completes its execution
If you think for a while about a sequence of these operations, you may notice that effect is still running when we read the signal value. Let’s modify the execute
function a bit to see what I’m trying to say:
function execute() {
dispose()
console.log('Hello!')
_externalCleanup = cb()
console.log('Bye!')
}
/*
Hello!
Count has changed! 0
Bye!
*/
And guess what? The console output will follow the exact sequence demonstrated in the code snippet above. JavaScript is single threaded, and this code runs synchronously. So what if could use that to intercept all the signals that were accessed while running an effect? We would then know for sure what signals our effect is depending on! Well, we’re getting closer to revealing the magic behind the Signals. Let’s do that!
let activeObserver = null
const signal = (value) => {
let _value = value
const _subscribers = new Set()
function read() {
if (activeObserver && !_subscribers.has(activeObserver)) {
_subscribers.add(activeObserver)
}
return _value
}
function write(value) {
_value = value
}
return [read, write]
}
const effect = (cb) => {
let _externalCleanup // defined explicitly by user
const effectInstance = {}
function execute() {
dispose()
activeObserver = effectInstance
_externalCleanup = cb()
activeObserver = null
}
function dispose() {
if (typeof _externalCleanup === "function") {
_externalCleanup()
}
}
execute()
return dispose
}
Now it’s getting clear how we track the effect’s dependencies. But wait, effect still has zero knowledge about its dependencies, but signal on the other hand knows which effect was running when its value was read. And yes, this is true. Effect, in fact, doesn’t need to know what it depends on to re-run, because effect re-runs are triggered by signals, not by effect itself. Though effect needs to know its dependencies in order to clean itself up correctly before re-running. Let’s jump to the next portion of the code, this time we will cover more pieces, and finally make it work, I promise!
let activeObserver = null
const signal = (value) => {
let _value = value
const _subscribers = new Set()
function unlink(dep) {
_subscribers.delete(dep)
}
function read() {
if (activeObserver && !_subscribers.has(activeObserver)) {
_subscribers.add(activeObserver)
activeObserver.link(unlink)
}
return _value
}
function write(valueOrFn) {
const newValue = typeof valueOrFn === "function" ? valueOrFn(_value) : valueOrFn
if (newValue === _value) return
_value = newValue
for (const subscriber of [..._subscribers]) {
subscriber.notify()
}
}
return [read, write]
}
const effect = (cb) => {
let _externalCleanup // defined explicitly by user
let _unlinkSubscriptions = new Set() // track active signals (to unlink on re-run)
const effectInstance = { notify: execute, link }
function link(unlink) {
_unlinkSubscriptions.add(unlink)
}
function execute() {
dispose()
activeObserver = effectInstance
_externalCleanup = cb()
activeObserver = null
}
function dispose() {
for (const unlink of _unlinkSubscriptions) {
unlink(effectInstance)
}
_unlinkSubscriptions.clear()
if (typeof _externalCleanup === "function") {
_externalCleanup()
}
}
execute()
return dispose
}
Let’s break it down and highlight the main changes:
- Signal intercepts the running effect through a global
activeObserver
variable and adds it to the_subscribers
set - Signal also links itself (actually, its
unlink
function) to the effect, so that effect can clean itself up before the next re-run - When signal’s value changes, it notifies all the subscribed effects about that change, so they could re-run
- Signal’s
write
function now accepts either primitive value or callback, such asprev => prev + 1
, to handle updates based on previous value - Effect tells signals that it’s running by assigning its instance to a global
activeObserver
variable - Effect instance exposes 2 functions to signals:
link
andnotify
, to track dependent signals and get notified when they change - Effect tracks its signal dependencies in the
_unlinkSubscriptions
set - Effect’s
dispose
function cleans up all its dependent signals to prevent memory leaks and maintain only active dependencies (if some signals are read conditionally, you know)
You may wonder why we transform _subscribers
set into array before iterating over it in the signal
function. Imagine a situation, where effect not only reads the signal value, but also changes it. Since these operations occur synchronously, they can create an infinite loop. If an effect reads a signal and changes its value, this change will trigger the effect to re-run, continuously modifying _subscribers
. This cycle will repeat endlessly. Using [..._subscribers]
ensures that we’re dealing with the snapshot of the _subscribers
at the time being, and thus changes to _subscribers
won’t affect the loop.
Visual diagram representing Signal / Effect relationships. “Execute” function is a starting point
Demo
Summary
And that’s it. The magic behind Signals is explained by getter functions to read values, and the single-threaded nature of JavaScript. So simple and powerful! But of course, the example code above is over-simplified and has enough room for further improvement.
In the next article, we will discuss how to:
- Let signals take initial value as a callback
- Support computed values, e.g.
fullName
that depends onfirstName
andlastName
signals and maintains the same reactivity - Support running multiple effects without interrupting each other (when one effect changes the signal and causes other effect to run). Currently, they will override each other, as
activeObserver
only holds single effect instance - Support batch updates, so effects re-run exactly once when multiple dependent signal values change sequentially (e.g.
firstName
andlastName
change one after another)
And as a cherry on the cake, we will build a simple JavaScript library to describe the DOM structure, similarly to React, and make it reactive with signals! You’ll see, it’s not that hard as you might think.
I hope you enjoyed my article, and I’ll see you in the next one!