Vue 3 Reactivity System Is Brilliant! Here’s How It Works - 
Part 2: reactive and watchEffect Functions

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 depsMaps 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:

  1. 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.

  2. 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

    1. if activeEffect exists:

      1. Get the correct depsMap of the target object, and create it if it doesn’t already exist.

      2. Get the correct set of effects of the property, and create it if it doesn’t already exist.

      3. Add the activeEffect to the list of effects of the current property.

      1. else (activeEffect doesn’t exist):

        1. do nothing
  1. 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

  1. Let's create a new file ourVue.js and add to it all the functions we’ve built so far. watchEffect, track, trigger, and reactive.

     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;
             },
         });
     }
    
  2. In our main js file, let’s import the reactive and effect functions from ourVue.js

     import { watchEffect, reactive } from './ourVue.js';
    
  3. 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!