Recreating Vue 3 Reactivity API (roughly)

Jason Yu
Attest Product & Technology
7 min readFeb 27, 2020

This article is roughly based off the talk I gave on 20th November 2019 at Vue.js London #13 meetup. You can find the video of the talk here and the repo here.

Typescript will be used in this article so we can look at the problem in a slightly different perspective. If you hate typescript, you can watch my talk instead which was in Javascript.

Introduction to Vue 3 Reactivity API

You can read about the Official Vue 3 Reactivity API. But here is a brief introduction with examples.

There are 4 functions in the reactivity API:

  1. reactive()
  2. ref()
  3. computed()
  4. watch()

Consider example 1:

This code uses reactive() and watch() from the reactivity API. reactive() creates an reactive object, i.e. the retrieval and setting of any properties will be tracked. watch() takes in a callback that will be executed immediately; whenever the callback's dependencies are changed, the callback will be evaluated again.

So in this example, car.position is updated every 1000ms. And we will see the car moving from the right to the left.

Consider example 2

This code uses ref(), computed() and watch(). ref() and computed() both returns a Ref. A Ref is simply defined as:

From the example, ref(0) returns { value: 0 } where the value will be reactive. computed() takes in a function returns a Ref whose value is whatever the function returns.

Hopefully this quick introduction through examples makes sense. If you still have some doubts, make sure you read the official description of the Vue 3 Reactivity API before reading the rest of the article.

Quick introduction to ES6 Proxy

Proxy is an ES6 feature; it is the real magic behind Vue 3's reactivity. You can see the full documentation here.

In this introduction, I am just going to include the parts we need from proxy to create reactivity.

Proxy is an object which allow us to programmatically control how it behaves on native operations.

Consider example 3

Here is the output:

=== start p.x = 3 ===
setTarget === target -> true
{ key: 'x', value: 3 }
=== end p.x = 3 ===

=== start getting p.x} ===
getting setTarget[x]: 5
=== end getting p.x} ===
p.x: nope

=== start getting p.y} ===
getting setTarget[y]: undefined
=== end getting p.y} ===
p.y: nope

{ x: 5 }

Please note that the reason for key: string | number is because Typescript currently cannot handle symbols as keys in objects. This is so stupid and there is a 5-year-old issue created regarding this. key will be typed as string | number | symbol otherwise.

As you can see in the example, we have set up the set and get trap for the proxy p. Whenever p's property is set or retrieved, our traps will be called and we can change how it behaves.

In this example, we always return 'nope' in the get function. This is why we see 'nope' for both p.x and p.y.

If you are still unsure about how Proxy works, make sure you read more into it in the mdn documentation.

Let’s recreate Vue 3’s reactivity API

You should be familiar with Vue 3’s reactivity API and Proxy by now. Let's now try to recreate Vue 3's reactivity API.

reactive() and watch()

Let’s recall example 1:

Our aim in this section is to make example 1 work with our custom reactive() and watch().

Brute-force “reactivity”

We can quickly make example 1 work as expected by simply calling the watchers ( watch() callbacks) whenever a reactive property is set. Let's implement this first and see where we can depart from there.

First, let’s keep track of the watchers in watch().

Pretty straightforward. Now we have a list of watchers. Next we have to trigger them whenever a reactive property is changed.

We can achieve this by having reactive() to return a proxy whose set trap will trigger all watchers.

Three things to note:

  1. Please note that the reason for key: keyof T is because Typescript would require key to be a key of T before being able to do target[key] = value. Without : keyof T, key will be typed as stirng | number | symbol which introduces another problem with the 5-year-old issue mentioned earlier.
  2. Previously string | number was sufficient because the target was a Record<any, any>, so typescript knows that the target can be extended.
  3. return true on line 14 indicates that the assignment is successful. Read more about it here.

An example to illustrate how the type works.

Exporting our watch() and reactive(), and using Example 1, we can now replace Vue’s reactivity implementation with our own

Example 4:

And the car is moving! ✅

There are couple of problems with this approach:

  1. Watchers will be called N times if we trigger mutate reactive object N times
    Watchers should only be fired once after a series of consecutive mutation. Currently each mutation will trigger the watchers immediately.
  2. Watchers will be called even when it doesn’t need to
    Watchers should only be reevaluated whenever their dependencies changes. We currently do not care and call the watchers whenever somethings is mutated.

Brute-force reactivity (fixing problem 1)

We aim to solve the first problem, i.e. watchers will be called N times if we trigger mutate reactive object N times, as mentioned in the last section.

To illustrate the problem, I have modified the code to add one more car which will trigger another mutation in the interval. You can see the code in example 5.

You can see how the callCount increments by 2. This is because there are two mutations happening every 1000ms so the watcher was called twice every 1000ms.

Our aim is to have the watchers only called once after a series of consecutive mutations.

How do we achieve this? “Firing something only once after a series of invocation”? Does this sound familiar? We actually have probably encountered this already in many places. For example, showing search suggestions only after user has stopped typing for a while; firing scroll listener once only after the user has stopped scrolling for a while?

Debounce! Yes, we can just debounce the watchers. This will allow a series of mutation finish before triggering the watcher. And it will only do it once! Perfect for this use case!

I will just use lodash’s debounce here so we won't need to implement it.

See example 6:

You can see how the callCount only increment by 1 every 1000ms.

Dependency tracking

The second problem: “watchers will be called even when it doesn’t need to”, can be solved with dependency tracking. We need to know what a watcher depend on and only invoke the watcher when those dependencies are mutated.

In order to illustrate the problem, I have modified the index.ts.

With this example, we can see the problem clearly. We expect r1.x to be logged every second and r2.x every 5 seconds. But both values are logged every second because all watchers are called.

Here are the steps we can implement dependencies tracking:

  1. We can keep track of the dependencies of a watcher in a Set, which helps avoid duplications. A dependency is a property in a reactive. We can represent each property in a reactive with a unique identifier. It could be anything unique but I'll use a Symbol() here.
  2. Clear the dependencies set before calling the watcher.
  3. When a reactive property is retrieved, add the symbol representing that property to the dependencies set.
  4. After the watcher callback finishes, dependencies will be populated with symbols that it depends on. Since each watcher now relates to a set of dependencies, we will keep { callback, dependencies} in the watchers list.
  5. Instead of triggering all watchers as a property is being set, we could trigger only the watchers that depend on that property.

With this we can see the result matches our expectation and this means dependency tracking is working!!!

Update dependencies on the fly

A watcher may change its dependencies. Consider the following code:

In this example, we expect the log to happen after 1 second and every 500ms afterwards.

However our previous implementation only logs once:

This is because our watcher only access r1.x at its first call. So our dependency tracking only keeps track of r1.x.

To fix this, we can update the dependencies set every time the watcher is called.

This wraps the dependency tracking into the watcher to ensure the dependencies is always up to date.

With this change, it is now fully working! 🎉

ref() , computed()

We can build ref() and computed() pretty easily by composing reactive() and watch().

We can introduce the type Ref as defined above:

Then ref() simply returns a reactive with just .value.

And a computed() just return a ref which includes a watcher that updates the value of the ref.

See the following example:

Conclusion

Thanks for reading this tedious article and hope you have gained some insights about how the magic behind Vue’s reactivity works. This article has been worked on across months because I travelled to Japan in the middle of writing this. So please let me know if you spot any mistakes/inconsistency which can improve this article.

The reactivity we have built is just a really rough naive implementation. There are so many more considerations put into the actual Vue 3 reactivity. For example, handling Array, Set, Map; handling immutability etc. So please do not use these code in production.

Lastly, hopefully we will see Vue 3 soon and we can make use of this amazing api to build awesome things! Happy coding!

--

--