VueNuxt.dev
Javascript Insights

Signals

Reactivity without frameworks

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.

Continue to Deep Dive

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.

  1. assign fn to our subscriber flag
  2. run the fn
  3. reset our susbcriber flag back to null
  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)));
Open Playground

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 our count.

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)));
Open Playground

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 a get call to count, ensuring the effect logic is added to its subscriptions.

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:

#ObservableSignal
1EventDrivenEmitter
2One-to-ManyMany
3SystemPushHybrid (Push-Pull)
4StructureClassGetter/Setter
5SubscriptionManualAutomatic
6UnsubscribeManualAutomatic
7SubscribersDirectionalGraph
8Dependencies⛔️
9Memoization

I will use the row number in parenthesis to reference the table in the text below. For example, (4) refers to the structure 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.


Copyright © 2025