Vue 3 Reactivity System Is Brilliant! Here’s How It Works - Part 2: reactive and watchEffect Functions
Last Checkpoint
Before we dive into this section, I wanted to bring to your attention that it builds upon the topics covered in the first part. If you haven't already read it, I recommend taking a few moments to do so before proceeding. It will provide helpful context for what follows. Without further ado, let's jump right in!
In the previous part of this series, we’ve figured out a way to tell JavaScript "Hey, totalPrice
is depending on price
and quantity
. Whenever any of them changes re-compute the totalPrice
".
The only issue with our solution so far is that we’re filling the values of depsMap
and targetMap
manually and we need to maintain them.
In this article we're going to continue building on what we’ve built so far, we’re going to fill the values of depsMap
and targetMap
automatically without our intervention. In the last part, we built the setter function of the JavaScript proxy. Today, the turn is on the getter.
A Quick Refresher
Let's take a moment to recall a key term from our previous article. As you may remember, the updateTotalPrice
function was called an effect because it modifies the state of the program. Therefore, whenever we mention an effect we mean a function that changes the state.
In the previous article, we introduced what was called depsMap
. It’s just a regular Map
where the key is the property name and the value is a Set
of effects that should be called whenever this property changes. In our example here, whenever the price
changes we need to recall all the effects in the set (updateTotalPrice
and updatePriceAfterDiscount
).
However, to be able to handle multiple reactive objects. We’ve introduced a new layer targetMap
. Which is basically a WeakMap
where the key is the reactive object itself and its value points to the depsMap
of that object.
The main purpose of us today is to be able to fill those depsMap
s automatically. We need to figure out a way to detect which effects depend on which properties.
What Depends On What?
Let’s ask ourselves a question, “How did we know that totalPrice
is depending on price
and quantity
?” How did we know that any changes to these variables require us to recalculate totalPrice
? The answer simply lies in the implementation of the updateTotalPrice
effect itself.
function updateTotalPrice() {
totalPrice = product.price * product.quantity
}
We can see that in order to compute totalPrice
we need to have the values of price
and quantity
. In other words, we need to get those values.
In simple terms, when we get the value of a property inside an effect, we can consider that effect to be a subscriber to that property. For example, updateTotalPrice
is a subscriber to the price
and quantity
properties because it retrieves their values in its implementation.
Thus, this should be the responsibility of the getter to track the dependencies.
Location Matters: Where Are We Getting The Property
Before implementing the getter handler, we need to differentiate between 2 cases:
Accessing a property inside an effect
In this case, the getter has to know which effect is being called and to update the
depsMap
accordingly.Accessing a property outside an effect
In this case, the getter has nothing to do. We’re not tracking dependencies here and we will return the property directly.
Differentiating Between The Two Cases: Creating watchEffect
Function
This differentiation can be done by defining a global variable activeEffect
and then within the getter, we can then check for the presence of this variable to determine what to do.
We need to ensure that the activeEffect
variable always points to the current effect being executed. To do so, we can set activeEffect
before executing the effect and then reset it again after the effect has been completed.
let activeEffect = null; // Global variable
activeEffect = updateTotalPrice;
updateTotalPrice();
activeEffect = null;
To clean things up a little bit, Vue is wrapping those lines in a function watchEffect
. This function takes the effect (function to be executed) as a parameter, sets activeEffect
correctly, executes the effect, and then resets activeEffect
back again at the end.
let activeEffect = null; // Global variable
function watchEffect(effect){
activeEffect = effect;
effect();
activeEffect = null;
}
watchEffect(updateTotalPrice);
We can now distinguish between the two cases: accessing a property inside an effect and accessing a property outside an effect. If we're getting a property, we can check whether an activeEffect
exists or not. The value of activeEffect
indicates the current effect, if there is one, and null otherwise.
Implementing The Getter
So far, we kept the getter function empty and focused only on the setter. It’s time to start implementing the getter function.
The main purpose of the getter function is to track the dependencies, i.e. to know that the updateTotalPrice
effect depends on the price
and to update the depsMap
accordingly. If there is no activeEffect
, the getter does nothing.
Breaking Down the Getter Function Step-by-Step
if
activeEffect
exists:Get the correct
depsMap
of the target object, and create it if it doesn’t already exist.Get the correct set of effects of the property, and create it if it doesn’t already exist.
Add the
activeEffect
to the list of effects of the current property.
else (
activeEffect
doesn’t exist):- do nothing
Return the value from the object as expected.
Let’s turn the previous algorithm to code:
let proxiedProduct = new Proxy(product, {
get(target, property) {
if (activeEffect) {
// We get the correct depsMap using the target (reactive object)
let depsMap = targetMap.get(target)
if (!depsMap) {
depsMap = new Map();
targetMap.set(target, depsMap);
}
// Get set of effects of dependency
let dep = depsMap.get(property)
if (!dep) {
dep = new Set();
depsMap.set(property, dep)
}
dep.add(activeEffect);
}
return target[property];
},
set(target, property, value) {
// ... Same as previous article, No change
}
})
Polishing The Code
So far, we’ve built both the setter (in the first part) and the getter traps for the proxy object. It’s time to clean and refactor the code.
Creating track
Function
Since the main purpose of the getter trap is to track the dependencies. We will filter out its code into a separate function track()
function track(target, property) {
if (activeEffect) {
// We get the correct depsMap using the target (reactive object)
let depsMap = targetMap.get(target)
if (!depsMap) {
depsMap = new Map();
targetMap.set(target, depsMap);
}
// Get list of effects of dependency
let dep = depsMap.get(property)
if (!dep) {
dep = new Set();
depsMap.set(property, dep)
}
dep.add(activeEffect);
}
}
and then in the proxy, we can use it directly as follows:
let proxiedProduct = new Proxy(product, {
get(target, property) {
track(target, property);
return target[property];
},
set(target, property, value) {
//...
},
});
Creating trigger
Function
We need to do the same for the setter trap since its main function is triggering all the effects whenever a property changes. We will filter its code out into a new function trigger
The code for the setter was explained in depth in the previous part of the series.
function trigger(target, property) {
// We get the correct depsMap using the target (reactive object)
let depsMap = targetMap.get(target)
if (!depsMap) return
// Get list of effects of dependency
let dep = depsMap.get(property)
if (!dep) return
// Run all the effects of this dependency
dep.forEach(effect => {
effect()
})
}
and then update the proxy accordingly
let proxiedProduct = new Proxy(product, {
get(target, property) {
track(target, property);
return target[property];
},
set(target, property, value) {
let oldValue = target[property];
target[property] = value;
// Call update function only if the value changed
if (oldValue != value) {
trigger(target, property);
}
return true;
},
});
Creating reactive
Function
Note that the proxy handler (setter and getter) is not specific to any particular object. i.e. it does not rely on the product
object in our example. Instead, it is designed to be generic and can be used for any reactive object in the future. To facilitate this, we will create a new function called reactive
, which accepts an object and returns a reactive version (proxied object) of the original.
function reactive(targetObject) {
return new Proxy(targetObject, {
get(target, property) {
track(target, property);
return target[property];
},
set(target, property, value) {
let oldValue = target[property];
target[property] = value;
// Call update function only if the value changed
if (oldValue != value) {
trigger(target, property);
}
return true;
},
});
}
And then, we can use our reactive
object directly and it will return a reactive version of that object.
let product = reactive({ price: 20, quantity: 5 })
Wrapping It All Up
So far, we’ve built simple versions of some of Vue’s core functions. Let’s put all of this into action. We're going to build a simple reactivity example
Let's create a new file
ourVue.js
and add to it all the functions we’ve built so far.watchEffect
,track
,trigger
, andreactive
.const targetMap = new WeakMap(); const depsMap = new Map(); let activeEffect = null; export function watchEffect(effect) { activeEffect = effect; effect(); activeEffect = null; } function track(target, property) { if (activeEffect) { // We get the correct depsMap using the target (reactive object) let depsMap = targetMap.get(target) if (!depsMap) { depsMap = new Map(); targetMap.set(target, depsMap); } // Get list of effects of dependency let dep = depsMap.get(property) if (!dep) { dep = new Set(); depsMap.set(property, dep) } dep.add(activeEffect); } } function trigger(target, property) { // We get the correct depsMap using the target (reactive object) let depsMap = targetMap.get(target) if (!depsMap) return // Get list of effects of dependency let dep = depsMap.get(property) if (!dep) return // Run all the effects of this dependency dep.forEach(effect => { effect() }) } export function reactive(targetObject) { return new Proxy(targetObject, { get(target, property) { track(target, property); return target[property]; }, set(target, property, value) { let oldValue = target[property]; target[property] = value; // Call update function only if the value changed if (oldValue != value) { trigger(target, property); } return true; }, }); }
In our main js file, let’s import the
reactive
andeffect
functions fromourVue.js
import { watchEffect, reactive } from './ourVue.js';
Use our
reactive
function to have a reactive object// Creating the Proxied Product let product = reactive({ price: 20, quantity: 5 }); let totalPrice; // Effects watchEffect(() => { totalPrice = product.price * product.quantity; }) console.log(totalPrice); // Returns 100 // totalPrice should be updated on updating the price product.price = 30; console.log('✅ After Updating Price'); console.log(totalPrice); // Returns 150 product.quantity = 10; console.log('✅ After Updating Quantity'); console.log(totalPrice); // Returns 300
If you're interested in experimenting with the example above, you can access the relevant files and code here.
Next Steps
So far, we have developed a simple and basic version of Vue's reactive
function. While Vue's version is significantly more complex and capable of handling numerous edge cases and potential errors, the fundamental concept remains the same.
In the next part of this series, we will explore the ref
function in detail. We will discuss its implementation, and highlight the key differences between ref
and reactive
functions. Be sure to stay tuned!