Exploring Vue 3’s reactivity: ref and watchEffect

October 15th, 2022

This is a series about exploring Frontend frameworks' internals. Its purpose is to shed some light on the way these frameworks operate under the hood. It may not be suitable for beginners or those who don't often need to know what's happening underneath their code.

And in second first part of the series, let's look into how Vue 3's reactivity works with ref and watchEffect.

Vue 3's reactivity

Within the scope of this article, we'll only explore ref and watchEffect for now. The other reactivity functions will be explored in the next part of the series.

We're talking about reactivity a lot here, so what is it really? It's actually not a new paradigm. The typical example is an Excel spreadsheet:

Excel spreadsheet example

In the example above, cell B1 is defined as = A1 + A2. When you update A1 or A2, B1 will also be reactively updated.

However, in JavaScript, variables don't work that way:

let A1 = 1
let A2 = 4
let B1 = A1 + A2

console.log(B1) // 5

A1 = 3

console.log(B1) // still 5

If our purpose is only to log the sum of A1 and A2 when either A1 or A2 changes, we can write something like this in Vue 3:

const A1 = ref(1)
const A2 = ref(4)

watchEffect(() => {
  console.log('B1 =', A1.value + A2.value)
})

But how does it work? How does watchEffect magically know when A1 or A2 changes? To answer this question, let's build ref from scratch. It begins with an object with only 1 property - value - with its getter and setter:

export function ref(value) {
  return new RefImpl(value)
}

class RefImpl {
  private _value

  constructor(value) {
    this._value = value
  }

  get value() {
    return this._value
  }

  set value(newVal) {
    this._value = newVal
  }
}

Nothing happens here yet. The magic trick we're going to use here is when the value property of the ref object is read (the getter method is called), we will automatically add the caller as the subscriber of the ref object. We also call this subscriber an effect (short for side effect). The ref object now becomes a dependency of the effect.

export function ref(value) {
  return new RefImpl(value)
}

class RefImpl {
  private _value
  public dep = undefined // Ironically, in the Vue's codebase, the subscribers/effects of a ref object seem to also be called its dependencies.

  constructor(value) {
    this._value = value
  }

  get value() {
    trackRefValue(this)

    return this._value
  }

  set value(newVal) {
    this._value = newVal;
  }
}

export function trackRefValue(ref) {
  if (!activeEffect) return;

  if (!ref.dep) ref.dep = new Set() // We must use Set here to avoid duplication

  ref.dep.add(activeEffect) // Add the active (currently running) effect as one of the ref object's subscribers
}

Now there's 2 problems left:

  1. Where does the value of activeEffect come from? In other words, how do we know which effect is currently running?
  2. We need to trigger/inform all of a ref object's subscribers/dependencies when its value property changes.

Let's deal with the second problem first because it's quite straightforward:

export function ref(value) {
  return new RefImpl(value)
}

class RefImpl {
  private _value
  public dep = undefined // Ironically, in the Vue's codebase, the subscribers/effects of a ref object seem to also be called its dependencies.

  constructor(value) {
    this._value = value
  }

  get value() {
    trackRefValue(this)

    return this._value
  }

  set value(newVal) {
    this._value = newVal

    triggerRefValue(this)
  }
}

export function trackRefValue(ref) {
  if (!activeEffect) return;

  if (!ref.dep) ref.dep = new Set() // We must use Set here to avoid duplication

  ref.dep.add(activeEffect) // Add the active (currently running) effect as one of the ref object's subscribers
}

export function triggerRefValue(ref) {
  if (!ref.dep) return;

  for (const effect of ref.dep) {
    effect() // run the effect
  }
}

Now the most important question left is: Where does the value of activeEffect come from? Let's take a look at the rawest implementation of watchEffect:

export let activeEffect = undefined

export function watchEffect(effectHandler) {
  const effect = () => {
    activeEffect = effect

    effectHandler()
    // The above function call will read the value property of any ref object inside it
    // and trigger the getter method of the value property,
    // which in turn adds this effect as one of the subscribers of that ref object.

    activeEffect = undefined
  }

  effect() // watchEffect triggers the effect (handler) immediately
}

Because watchEffect always runs immediately, the first time it runs will always trigger the getter methods of the value property of ref objects inside it, and registers the effect as a subscriber of these ref objects. This is why you don't need to explicitly specify the dependencies for watchEffect.

Here's a working code example of ref and watchEffect:

{% codesandbox stupid-vue-reactivity-reproduction-1-ivvknd %}

For now, we have a roughly working version of ref and watchEffect. It is nowhere near usable because we've left out too many cases where it might fail, plus there's no batching, and the flow of control is a little bit messed up here. But it serves its purpose as an oversimplified example of what's happening behind the scenes, hopefully.

Conclusion

In this part of the series, we've tapped into the gist of Vue 3's ref and watchEffect. In the next part, we'll continue to improve the oversimplified version of ref and watchEffect to match the real implementaion more closely, and we'll also explore reactive, computed, and watch.

I hope that you find this useful somehow. If you have any suggestion or advice, please don't hesitate to reach out to me in the comment section.

Tags
Frontend Development
Technology
share icon
Share