Implement Vue.js Reactive System From Scratch

A tutorial on implementing a Vue.js Reactive system.

Ehsan Movaffagh
Bits and Pieces

--

Hello there! As promised, in this article, we are going to implement the Vue.js reactive system, which we learned from my previous article. Let’s begin!

Effect

In this section, we are going to implement The Effect class; Please see the code below:

let activeEffect;

class Effect {

constructor(fn) {
this.fn = fn
}

run() {
activeEffect = this
const result = this.fn()
activeEffect = undefined
return result
}
}


function effect(fn) {
const _effect = new Effect(fn)
_effect.run()
return _effect
}

As you can see if the run method of any Effect is executed, the activeEffect will refer to our current effect, whose run method is currently being executed. By executing the run, we add the activeEffect to all of the target’s dependencies in the fn function.

Track and Trigger

As I mentioned in my previous article, we need to trigger our target whenever there are updates to it in order to update all values or obtain new values from it. This is necessary because we require all the new values or updates for this target. If we read a value from our target, we need to track it to determine which targets this current target is dependent on. I also need to mention that the target could be a computed value or a ref value and etc.

function track(target) {
// if we have any activeEffect
if (activeEffect) {
target.dep.add(activeEffect)
}
}

function trigger(target) {
const effects = [...target.dep]
for (const effect of effects) {
effect.run()
}
}

Ref

It’s time to implement our first target: Ref!

This is very simple. We just need to trigger after each set, track the value retrieval, and it’s done!

class Ref {
constructor(value) {
this.dep = new Set()
this.value = value
}

set value(value) {
this._inner_value = value
trigger(this)
}

get value() {
track(this)
return this._inner_value
}
}

function ref(value) {
return new Ref(value)
}

Test Effect with Ref

Now let’s test it with this code:

const firstname = ref('Ehsan')
const lastname = ref('Movaffagh')
let fullname = ''

effect(() => {
fullname = `${firstname.value} ${lastname.value}`
})

console.log(fullname) // Ehsan Movaffagh

firstname.value = 'banana'

console.log(fullname) // banana Movaffagh

Wow! This is great. The fullname value has changed after we modified the firstname. It is because, after each change, the effect method will be executed.

Computed

Now it is time for a more complicated one: computed values! Computed values have getter functions, and within these functions, we have one or more targets. Therefore, we need to create an effect for that getter function to understand when those target values are updated.

class Computed {
constructor(name, getter) {
this.dep = new Set()
this._cached_value = undefined
this.effect = effect(() => {
console.log(`run computed effect ${name}`)
this._cached_value = getter()
trigger(this)
})
}

get value() {
track(this)
return this._cached_value
}
}

function computed(name, fn) {
return new Computed(name, fn)
}

I added that console.log to show you when the computed value effect will run!

Test Computed

Now let’s test it with this code:

const number = ref(1)
const number2 = ref(2)


const sum = computed('sum', () => {
return number.value + number2.value
})
// run computed effect sum


console.log(sum.value) // 3

number.value = 2
// run computed effect sum

console.log(sum.value) // 4

Let’s create another computed value with sum:

const sumDescription = computed('sumDescription', () => {
return `sum(${number.value}, ${number2.value}) = ${sum.value}`
})
// run computed effect sumDescription

console.log(sumDescription.value) // sum(2, 2) = 4

number.value = 1
// run computed effect sum
// run computed effect sumDescription
// run computed effect sumDescription

console.log(sumDescription.value) // sum(1, 2) = 3

Wait a second! Why there are two logs of sumDescription? The answer is one of them is for number and another one is for sum. Well, this is bad for performance! What can we do to prevent this issue?

Solve performance issue

To solve this issue, we need to determine the appropriate time to update the target’s dependencies values. We already know when that is: when we need to read the value, right?!

To achieve this, we need to add a scheduler method to our effects. This scheduler should indicate when we should execute the run method of our effect. In the trigger, we will execute the scheduler instead of the run method of any effect.

Effect

Initiate effect with fn and scheduler.

class Effect {

constructor(fn, scheduler) {
this.fn = fn
this.scheduler = scheduler
}

run() {
activeEffect = this
const result = this.fn()
activeEffect = undefined
return result
}
}

function effect(fn, scheduler) {
const _effect = new Effect(fn, scheduler)
_effect.run()
return _effect
}

Trigger

The scheduler should be executed instead of run.

function trigger(target) {
const effects = [...target.dep]
for (const effect of effects) {
if (effect.scheduler) {
effect.scheduler()
} else {
effect.run()
}
}
}

Computed

Our changes in computed are simple. We added a new _dirty attribute to track whether the computed value is dirty, indicating that it needs to be updated when we read the computed value.

After we read the new value and update the _cached_value, we don’t need to execute the effect again; we can simply read from the _cached_value.

class Computed {
constructor(name, getter) {
this.dep = new Set()
this._cached_value = undefined
this._dirty = true
this._name = name
this.effect = new Effect(getter, () => {
if (!this._dirty) {
this._dirty = true
trigger(this)
}
})
}

get value() {
track(this)
if (this._dirty) {
this._dirty = false
console.log(`run computed effect ${this._name}`)
this._cached_value = this.effect.run()
}
return this._cached_value
}
}

I need to mention that we use the Effect constructor instead of the effect function because the effect function will execute the getter, which is not what we want. We want to run the getter function only when we need to read the computed value.

Test

And now we need to test our new implementations.

const sumDescription = computed('sumDescription', () => {
return `sum(${number.value}, ${number2.value}) = ${sum.value}`
})

console.log(sumDescription.value)
// run computed effect sumDescription
// sum(2, 2) = 4


number.value = 1

console.log(sumDescription.value)
// run computed effect sum
// run computed effect sumDescription
// sum(1, 2) = 3

As you can see, there is only one sumDescription log because the effect runner will only be executed when we need it!

Conclusion

And here we are! We are done! We have implemented Effect, trigger, track, ref, and computed. The reactive and computed with setter functionalities remain. I will leave it to you to implement them yourself. I will provide you with some tips for the reactive part: you can utilize Proxy and Reflect.

💡 Tip: If you find yourself using these functions in multiple projects, use Bit to share and reuse it. This way, you’ll have independent versioning, tests, and documentation for them too, making it easier for others to understand and use your code. No more copy/pasting repeatable code from repos.

Learn more:

Hope you enjoy it!

Build composable Vue apps with reusable components, just like Lego

Bit is an open-source toolchain for the development of composable software.

With Bit, you can develop any piece of software — a modern web app, a UI component, a backend service or a CLI script — as an independent, reusable and composable unit of software. Share any component across your applications to make it easier to collaborate and faster to build.

Join the 100,000+ developers building composable software together.

Get started with these tutorials:

→ Micro-Frontends: Video // Guide

→ Code Sharing: Video // Guide

→ Modernization: Video // Guide

→ Monorepo: Video // Guide

→ Microservices: Video // Guide

→ Design System: Video // Guide

--

--

Full-stack developer & software engineer, crafting innovative solutions to shape the digital world🚀; https://linkedin.com/in/ehsan-movaffagh