Reactivity in vanilla JavaScript

Auteur(s) de l'article

What is reactivity ? It's when a system, an app, reacts to a change of its data. Following an event, the view of the page is updated without having to reload the page.
All current JavaScript frameworks handle reactivity under the hood, in order to optimize performances. This lets the developer to focus on the business logic instead.
So why implement reactivity in plain JavaScript ? And how do we it ?
For the why: To better understand how the frameworks we use daily work. As much as it seems a bit like magic sometime, we can reproduce the same reactivity in plain JavaScript. Let's now see how.

Let's build a reactive app in pure JavaScript

In React, I'm creating the following code:
import React, { useState } from 'react';

const Fruit = () => {
  const price = 5;
  const [quantity, setQuantity] = useState(4);

  const calculateTotal = () => quantity * price;

  return (
    <div>
      <p>{calculateTotal()}</p>
      <button type="button" onClick={() => setQuantity(quantity + 1)}>
        Add one more fruit
      </button>
    </div>
  );
};
This would display the total (price * quantity). And underneath, I have added a button which allows us to increment the quantity of fruit. Every time I click on the button, the quantity state is updated, resulting in the calculateTotal() function to be called once again and displaying the updated result on the page.
How can I do the same thing in pure JavaScript ? Let's see step by step.
const price = 5;
let quantity = 4;
let total = 0;
const calculateTotal = () => { 
  total = quantity * price;
  console.log(total);
};

// Let's run our function
calculateTotal(); // 20

// record() is a new function which we need to store our function calculateTotal()
const storage = [];
const record = () => {
  storage.push(calculateTotal);
}
record(); 

quantity = 5;
// I need to re-call the calculateTotal function in order to see the updated total
calculateTotal(); // 25
Here we defined the calculateTotal() function, which we need to run every time we update either the quantity or price. It is not yet reactive, as we need to manually call it.
The record() function will be useful later when we will want to not only re-execute our calculateTotal() function, but also any other function we might have stored in it.
const replay = () => {
  storage.forEach(func => func());
}

// Then we can do something like that
// Before updating the quantity:
console.log(total) // 25 (quantity = 5 / price = 5)
quantity = 6;
// it will run every function we have stored in our storage, including our calculateTotal with the update quantity
replay();
console.log(total) // 30 (quantity = 6 / price = 5)
Now this works, but it is also not a great solution for an application which might scale up. So let's refactor our code and create a new class which will call Reactive.
class Reactive {
  constructor() {
    this.listeners = []; // the array which will hold all our stored functions and who will by run whenever we call our notify() function
  }
  depend() { // this replaces our previous record() function
    if (calculateTotal && !this.listeners.includes(calculateTotal)) {
      this.listeners.push(calculateTotal);
    }
  }
  notify() { // this replaces our replay() function
    this.listeners.forEach(listener => listener());
  } 
}
With our new Reactive class, we can now modify the code above:
const reactive = new Reactive(); // a new instance of our Reactive class

const price = 5;
let quantity = 4;
let total = 0;
const calculateTotal = () => { total = quantity * price };

reactive.depend(); // store our calculateTotal function
reactive.notify(); // run all stored function, including our calculateTotal
console.log(total); // 20

quantity = 5;
reactive.notify(); // we tell our Reactive class that there is a change to a dependency of one of its stored functions (here the calculateTotal function)
console.log(total); // 25
This works well 🙌 But we can see that our Reactive class is dependent on our calculateTotal() function. If, in the future, I would like to create another function, such as addTaxes(), I would need to re-create another Reactive class.
Instead, let's modify our code to not be dependent of calculateTotal() with the help of a watcher() function.
let target = null;
class Reactive {
  constructor() {
    this.listeners = [];
  }
  depend() {
    if (target && !this.listeners.includes(target)) {
      this.listeners.push(target);
    }
  }
  notify() {
    this.listeners.forEach(listener => listener());
  } 
}

/////////////// 

const reactive = new Reactive();

const price = 5;
let quantity = 4;
let total = 0;
const calculateTotal = () => { total = quantity * price };
const watcher = (myFunc) => { // our watcher function will handle adding myFunc to our Reactive instance, as well as running the myFunc
  target = myFunc;
  reactive.depend(); // add the target function to the array of listeners
  target(); // run the target function
  target = null; // reset the target variable to null
}

watcher(calculateTotal);
console.log(total); // 20

quantity = 5;
reactive.notify();
console.log(total); // 25
Great ! Now our Reactive class is no longer dependent of a specific function, that's what we wanted.
But we can see we still need to manually call reactive.notify() anytime the quantity or price are updated. Ideally, we would like that, anytime one of the variable quantity or price is updated, the code be reactive. Let's do that !
To achieve this, we will need to use Object.defineProperty - this lets us add or modify properties of an object. We also need to re-define our variables into an object data.
const data = { price: 5, quantity: 4 }; // replace our previous price and quantity variables

Object.keys(data).forEach(key => { // let's iterate through the keys of the data object
  let initialValue = data[key]; // we get the initial value of the current key
  Object.defineProperty(data, key, {
    get() { // define a getter which returns the value of a given key
      console.log(`get the value of ${key}: ${initialValue}`);
      return initialValue;
    },
    set(newValue) { // define a setter which modifies the value of a specific key
      console.log(`set the value of ${key} to ${newValue}`);
      initialValue = newValue;
    },
  });
});

const total = data.price * data.quantity
console.log(total);
Okay ! So we have now a data object which contains two properties, quantity and price, and each of them have their own setter and getter.
For the final refactoring, we will now use our Reactive class and instanciate it for each of the properties of the data object. This way, whenever we update any of the data properties, we will call the notify() method of the Reactive class automatically.
const data = { price: 5, quantity: 4 };
let target = null;

class Reactive {
  constructor() {
    this.listeners = [];
  }
  depend() {
    if (target && !this.listeners.includes(target)) {
      this.listeners.push(target);
    }
  }
  notify() {
    this.listeners.forEach(listener => listener());
  } 
}

////////////

Object.keys(data).forEach(key => {
  const reactive = new Reactive(); // we instantiate a new Reactive object for each of our property
  let initialValue = data[key];
  
  Object.defineProperty(data, key, {
    get() {
      reactive.depend(); // add the target function to the array of listeners
      return initialValue;
    },
    set(newValue) {
      initialValue = newValue;
      reactive.notify(); // every time the value of a property is updated, all the functions stored in this.listeners will be called again.
    },
  });
});

// we've removed the reactive.depend() as it is now handled in the Reactive instances of each properties
const watcher = (myFunc) => {
  target = myFunc;
  target();
  target = null;
}

watcher(() => {
  data.total = data.price * data.quantity;
});
The codepen shows the demo of the code above.
Of course, this is a very basic example of reactivity and how to implement it. All big frameworks, such as React or Vue, handle reactivity for us.
The question is when to update the state. All framework handle differently when to do it, but all of them do the update by batches.
By now, you should have a better understanding of what reactivity is and how it can be implemented.

Sources