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.