Signals
Intro
Signals
have emerged as a lightweight solution many frontend frameworks and libraries have quickly adopted. If you are curious to find out what makes them stand out, join me as I build a signal mechanism from scratch to understand better what they bring to the table, what exactly their secret sauce is, and why I think they can future-proof our code.
Background
I published a deep dive on Javascript reactivity for anyone interested in additional context into its technical evolution. It covers many interesting JS
key concepts that power signals.
Or you can skip it and continue with our topic at hand.
Signal
Our mechanism build starts with a new function that we name signal
. This function takes in a value
as an argument so we can initialize our state. We then assign this function to a const
variable to create a new instance of our signal
which has get
and set
methods for our stateful value
argument.
We will use a generic type T
so that we can infer
it from its initialization or we can modify it from the outside as needed.
Example
import { signal } from './signal'
// init
const count = signal(0)
// read value
count.value // 0
// set new value
count.value = 1
Our signal
function does not look very impressive yet. It is mainly boilerplate, but it already lets us get a feel for its external interface, which is how I like to start.
Subscriptions
Subscriptions
power reactivity in signals
. They allow us to bring in multiple pieces of outside logic and store them in the same scope as our state
to be run automatically after updates.
Let's add them into our signal
function. We need a new const
named subscriptions
placed before the return block so that it is scoped to our state
.
Now, for our data structure, we need to consider a few things. We do not want to limit the number of subscriptions we can add to our signal. Therefore, an array-like structure would be best. Also, we must consider preventing adding duplicate logic into our subscriptions, which could be a pain to debug.
Taking these concerns into account, a set
is better than a regular array
because:
- it is dynamically sized, allowing us to store as many effects as needed
- it is array-like because it has some iterable methods like:
forEach
- it safeguards us from introducing duplicates because only
unique
items are allowed.
Type-wise, our set
will take in Functions
, which we can specify through the generic.
const subscriptions = new Set<Function>()
Effect
Effects are the counterparts for our signal
; together, they establish a one-to-many
relationship that completes our reactivity mechanism. This means, one signal can have as many effects as needed. Each effect is an independent function that lets us subscribe
to state
mutations.
Think of it as:
if
signal changes,then
effect logic runs.
Let's create our new function, effect
, which needs to be in the same module as our signal
, but not inside it, decoupling them.
export const effect = (fn: Function): void => {
// ...
};
Effect
takes in another function, fn
, as an argument, of Function
type. Effectively pulling
outside logic into the scope of our module. To keep both decoupled functions in sync (signal <-> effect), we need a let
flag at the module's root; let's name it subscriber
and set it to null
at the top of the file. It's type can be either a Function
or Null
.
let subscriber: Function | Null = null
A subscriber is the subject who watches the signal. Subscriptions are what the subscriber sends into our signal, the actions or effects.
Now, we can finish our effect
function, which needs to do three things.
- assign
fn
to oursubscriber
flag - run the
fn
- reset our
susbcriber
flag back tonull
subscriber = fn;
fn();
subscriber = null;
Wiring
As a last step, we have to wire these two decoupled functions together to enable reactivity.
Let's begins by extending our signal
function a bit. First, we need to check if
the subscriber
has a function
assigned to it in the getter
method for our signal
value. If so, we add
this subscriber
value to our subscriptions
set.
get value() {
if (subscriber) subscriptions.add(subscriber);
return value;
},
Then, on our setter, after updating our state, we run a forEach
loop on subscriptions
to execute each function stored in our set with the updated value.
set value(newValue: T) {
value = newValue;
subscriptions.forEach((fn) => fn(value));
},
These last changes complete our signal mechanism, which is now full of reactivity goodness using only vanilla JS and without requiring any frameworks.
Reactivity
There you have it. We built a signals reactivity mechanism from scratch. This is where our code stands, including a usage example.
import { toValue, signal, effect } from './signals.js';
const count = signal(0);
const btn = document.querySelector('#counter');
btn.addEventListener('click', () => (count.value = toValue(count) + 1));
// 1st effect
effect(() => btn.innerText = `Count: ${toValue(count)}`);
// 2nd effect
effect(() => console.log('count', toValue(count)));
In our use case example, we create a new signal
for a const
named count
with an initial value of 0
. We then get our page's btn
(button) and add a listener to its click
event (observer). We increase our count
value by 1
on every click (mutation). We then proceed to add two effect functions. The first is to sync the inner text of the button element with the updated count
state. The second is to log the new count
to the console.
Whenever we click our button, we correctly update the fresh value in the element's label (1st effect) and log the updated value to the console (2nd effect).
How crazy is this? This devex
is what signals bring to the table: a simple reactive mechanism that can have profound implications for our code design. The power of signals is subtle but powerful. It takes a few read-throughs to entirely understand what is happening under the hood. So, let's continue by shinning some light into what makes signals
stand out with a practical example.
Derived
In Vue, we have various mechanisms to help simplify the complexities of nested reactivity. Examples include functions like computed
, watch
, or watchEffect
.
Signals
are different from other reactive mechanisms because, out of the box, without any effort or cost on our part, they handle nested reactivity beautifully. This is their superpower.
This superpower allows us to shift from a single level of subscribers to an autogenerated dependency graph 🤯. Let's explore what this means.
To avoid conflicts with Vue's core function computed,
let's add a new function with the name derived
in our signals
module below the effect
function.
export const derived = (fn: Function) => {
// ...
};
All we have to do inside our derived
function is to create a new signal dep
(dependency) initialized with the fn
we received. We then create a nested effect
function that assigns the execution of our received fn
to our dep
value—finally returning our dep
instance to end the function.
export const derived = (fn: Function) => {
const dep = signal(fn);
effect(() => dep.value = fn());
return dep;
};
Problem
At first glance, this might not seem like a superpower but let's consider the following use case:
Let's say we want to automatically
double
the value of ourcount
.
Vue
would solve this effortlessly with a computed
property. However, things are not so simple in vanilla JS world. Let's compare how we can accomplish this using the standard eventListeners
approach and one with only signals
(without derived
) with the same example.
let count
let double
const btn = document.querySelector('#counter');
btn.addEventListener('click', () => {
count++
double = count * 2
});
You can see how the withSignals
approach is better because we decouple the effect from the event. The event only mutates the count
, and then we react to this update using our effect
function.
With eventListeners
we are mixing concerns because, for memory considerations, we do not want to create multiple listeners, so we have no choice but to pile everything into a single place. Most importantly, the dependency between count
and double
is manual, which will be challenging to maintain because it needs to be remembered. Lastly, in frameworks like Vue
, we must manually unsubscribe
from these listeners
to avoid memory leaks.
Solution
Our derived
function could simplify all this by automating the dependency like so:
const doubled = derived(() => toValue(count) * 2);
Yes, we do not have to create a new signal for double
nor an additional effect
function for its side-effect because they are both nested in our derived
function. This nesting is powerful because it autoregisters effects while guaranteeing the correct execution order of multiple levels of async logic through a dependency graph created automatically by the call stack, outsourcing all the heavy lifting to JS
. Wow, that's a mouthful, but it quantifies how much signals simplify things for us.
Bypassing the async complexities of dealing with concurrency and race conditions from multiple parts of our code base is a superpower.
Example
import { toValue, signal, effect, derived } from './signals.js';
const count = signal(0);
const doubled = derived(() => toValue(count) * 2);
const btn = document.querySelector('#counter');
const span = document.querySelector('#derived');
btn.addEventListener('click', () => (count.value = toValue(count) + 1));
effect(() => btn.innerText = `Count: ${toValue(count)}`);
effect(() => span.innerText = `Doubled: ${toValue(doubled)}`);
effect(() => console.log('count', toValue(count)));
effect(() => console.log('doubled', toValue(doubled)));
In our example above, we update our count
value every time we click on our button, which will trigger the double
update after. Both these side-effects would trigger our four effect
functions, changing the inner text of HTML elements and fresh logs to the console. If we click on our button we see the following in the console:
doubled 2 main.js:35
count 1 main.js:35
The console log reveals the powers of recursion. The JS
call stack guarantees the execution order in our dependency graph. This automation compeletly eliminates any async headaches for us.
We can even nest dependencies like so:
const count = signal(0); // count.value = 1
const doubled = derived(() => toValue(count) * 2); // 2
const squared = derived(() => toValue(doubled) * 2); // 4
We have seen the superpower, but it might still not be clear why this is technically so, let's dig deeper to discover the secret sauce of signals.
Secret Sauce
The key to signals
is how they use the getter
method to register subscriptions
automatically. This process outsources the complex sequence of running multiple nested reactive relationships to the time-tested, highly optimized, and garbage-collected call stack
of our programming language, which is simply genius.
Autoregistration
With signals, we can automatically register the logic from effect
functions scattered across our codebase and dynamically pull
them into the proper scope. This autoregistration works because as JS
parses through the code in each effect
function and finds a signal
, it will trigger a get
call to the proper scope where we have wired logic to push the dependency into the subscriptions set in-scope
.
In other words, if we have an effect that depends on the
count
signal when JS parses through this function, it will trigger aget
call tocount
, ensuring the effect logic is added to itssubscriptions
.
Example
const count = signal(0)
const doubled = derived(() => toValue(count) * 2)
setTimeout(() => count.value+++, 888)
If we examine what is in our signal's subscriptions
after the timeout fires. We will find the following:
// Subscriptions
Set(1) {ƒ}
[[Entries]]
0: () => { derived.value = fn(); } // () => toValue(count) * 2
size: 1
You can see our nested dependency at 0
in our subcriptions
set for the count
signal. That is because the fn()
executes to () => toValue(count) * 2
. As JS
parses through this function it finds count
which triggers a get
call which pulls
the effect
logic from the subscriber
into the subscriptions
of count
.
If we were to extend this with another derived function like so:
const count = signal(0)
const doubled = derived(() => toValue(count) * 2)
const squared = derived(() => toValue(doubled) * 2)
setTimeout(() => count.value+++, 888)
Then we would have the following subscriptions autoregistered:
// Subscriptions
Set(1) {ƒ}
[[Entries]]
0: () => { derived.value = fn(); } // () => toValue(count) * 2
size: 1
Visualization
As you can see, thanks to our dependency graph autogenerated by the call stack. The squared
derived function makes double
pull it into its subscriptions
. Which in turn makes count
pull double
into its subs. When there is an update
, the count
will update, then push
changes to double
and finally squared
.
Our dependency graph's sequence is guaranteed because it follows the execution order in the call stack. Subscriptions are registered in the same order as JS adds to the call stack, and then dependencies are recalculated as JS executes it.
Comparison
For thoroughness, let's take a moment to compare them to observables. This comparison will help us better visualize the technical differences between the two.
Table
Signals and observables do many of the same things but they accomplish them through different mechanisms. Here is an overview of their differences in table format:
# | Observable | Signal | |
---|---|---|---|
1 | Event | Driven | Emitter |
2 | One-to- | Many | Many |
3 | System | Push | Hybrid (Push-Pull) |
4 | Structure | Class | Getter/Setter |
5 | Subscription | Manual | Automatic |
6 | Unsubscribe | Manual | Automatic |
7 | Subscribers | Directional | Graph |
8 | Dependencies | ⛔️ | ✅ |
9 | Memoization | ❓ | ✅ |
I will use the row number in parenthesis to reference the table in the text below. For example,
(4)
refers to thestructure
row.
Visualization
The subtle differences between the two are clearly highlighted by visualizations:
You can see that Observables
have a directional
(7) look. We push
(3) through the update
(1) method. We subscribe manually
(5) using a class's observable.subscribe()
(4) instance method. We can have as many
(2) subscriptions as needed, but we cannot nest dependencies
(8), we are limited to a single level. I'm unsure if the values are memoized
(9), but this is negligible without dependencies. We need to unsubscribe
(6) from each subscribe
method manually.
Signals
look more like a graph
(7). We use getter/setters
(4) to automatically
(5) register many
(2) subscriptions through a hybrid
(3) push-pull system because each signal is its own event-emitter
(1) from which we do not need to unsubscribe
(6). Enabling us to have multiple levels of reactivity with full memoized
(9) values that avoid unnecessary recalculations of nested dependencies
(8).
Future proof
With the proliferation of micro-frontends or even the islands' architecture, we find ourselves breaking out of the traditional SPA
model, where a single framework used to rule the page. This shift forces us to rethink the interoperability of mixing different areas of reactivity within a single page, and the wide adoption of signals
could take us there. In fact, there is an active TC39
proposal to include them into Javascript's ECMA
script. You can check it out here. If this moves forward, it will be a game-changer!.
What interests me most is that they signal (pun intended 😅) towards a future with framework-agnostic reactivity—enabling it to work in pages with multiple possible frameworks, mixed with native web components, or even with sections powered only by vanilla JS.