Implement signals with vanilla js

What are signals?

Signals are special kind of variables which can automatically ‘signal’ functions when they’re changed.

In context of frontend frameworks, signals will automatically update DOM elements when the signals are modified.

Implementation without using proxy objects

Most implementations you find online focus on using proxy objects to implement signals. In this blog post, we’ll focus on implementation of signals without the use of proxy objects. The goal is to better understand how the underlying mechanisms work.

Let’s start with a basic variable that returns a getter and setter function without any reactivity:

function signal(initialValue) {
   let value = initialValue;
   
   const getter = () => {
     return value;
   }
   
   const setter = (v) => {
     value = v;
   }
   
   return [getter, setter];
}

The value is stored in the closure context and as such not directly accessible.

Let’s add an array which will store all the functions that should be re-executed when the value changes.

function signal(initialValue) {
   let value = initialValue;
   let subscribers = [];
   
   const getter = () => {
     return value;
   }
   
   const setter = (v) => {
     value = v;
     subscribers.forEach(async (fn)=>{
       await fn();
     });
   }
   
   return [getter, setter];
}

Now every time the value is changed, we will re-run all the functions that depend on them. But how do we add functions to the subscribers array? Let’s write another function called createEffect, that will be used to run all reactive functions.

let current = null;

async function createEffect(fn: ()=>void) {
  current = fn;
  await fn();
  current = null;
}

current variable is used to store the reference of the function being run currently. We’ll use this variable to automatically store the reference to the subscribers array.

let current = null;

async function createEffect(fn: ()=>void) {
  current = fn;
  await fn();
  current = null;
}

function signal(initialValue) {
   let value = initialValue;
   let subscribers = [];
   
   const getter = () => {
     if(current && !subscribers.includes(current)) {
       subscribers.push(current);
     }
     return value;
   }
   
   const setter = (v) => {
     value = v;
     subscribers.forEach(async (fn)=>{
       await fn();
     });
   }
   
   return [getter, setter];
}

What the heck is going on? I know, just bear with me. We store reference to the current function being executed in the current variable. When we read the signal (call the getter function) in this current function, the reference to current function gets automatically added to the signal’s subscribers array. This way we don’t have to manage dependencies manually.

Let’s use this implementation to create some reactive code:

const [count, setCount] = signal(0);

createEffect(()=>{
  console.log("Count is ", count());
});

setCount(5);
setCount(10);

// Output:
// Count is 0
// Count is 5
// Count is 10

This is a very basic implementation of signals without the use of proxy objects. We can extend this for objects and arrays, however we will not delve into that in this post.

You can play around with this implementation on stackblitz:


Deepak Mittal

Powered by WordPress