If you like this article, chances are you'd like what I tweet as well. If you are curious, have a look at my Twitter profile. π
This post is the fourth part of a series called Create Your Own Vue.js From Scratch, where I teach you how to create the fundamentals of a reactive framework such as Vue.js. To follow this blog post, I suggest you read about the other parts of this series first.
Roadmap π
- Introduction
- Virtual DOM basics
- Implementing the virtual DOM & rendering
- Building reactivity (this post)
- Bringing it all together
What is state reactivity?
State reactivity is when we do something (react) when the state of our application (set of variables) changes. We do this in two steps:
- Create a "reactive dependency" (We get notified when a variable changes)
- Create a "reactive state" (Basically a collection of dependency variables)
1. Building a reactive dependency
Function to watch over changes
For this to work, we first need a function that is executed when a reactive dependency changes. As in Vue, this is called watchEffect
; we'll also call our function that.
In our example, this function looks like this:
function watchEffect(fn) {
activeEffect = fn
fn()
activeEffect = null
}
The global variable activeEffect
is a temporary variable where we store our function, passed to watchEffect
. This is necessary, so we can access the function when itself reads a dependency that refers to that function.
Dependency class
We can see a reactive dependency as a variable that notifies to its subscribers when it's value changes.
- It can be created with an initial value, so we need a constructor
- We need to subscribe a function to changes on the dependency. We'll call this
depend()
- We need a to notify subscribed functions of the dependency when the value changes. We'll call this
notify()
- We need to do something when the value gets read or written, so we need a getter and a setter
So our skeleton will look like this:
class Dep {
// Initialize the value of the reactive dependency
constructor(value) {}
// Subscribe a new function as observer to the dependency
depend() {}
// Notify subscribers of a value change
notify() {}
// Getter of the dependency. Executed when a part of the software reads the value of it.
get value() {}
// Setter of the dependency. Executed when the value changes
set value(newValue) {}
}
The class has two fields: value
(value of the dependency) and subscribers
(set of subscribed functions).
We implement this step by step.
Constructor
In the constructor, we initialize the two fields.
constructor(value) {
this._value = value // not `value` because we add getter/setter named value
this.subscribers = new Set()
}
subscribers
needs to be a Set
, so we don't repeatedly subscribe to the same function.
Subscribe a function
Here, we need to subscribe a new function as an observer to the dependency. We call this depend
.
depend() {
if (activeEffect) this.subscribers.add(activeEffect)
}
activeEffect
is a temporary variable that is set in the watchEffect
which is explained later on in this tutorial.
Notify subscribers of a dependency change
When a value changes, we call this function, so we can notify all subscribers when the dependency value changes.
notify() {
this.subscribers.forEach((subscriber) => subscriber())
}
What we do here is to execute every subscriber. Remember: This is a subscriber is a function
.
Getter
In the getter of the dependency, we need to add the activeEffect
(function that will be executed when a change in the dependency occurs) to the list of subscribers. In other words, use the depend()
method we defined earlier.
As a result, we return the current value.
get value() {
this.depend()
return this._value
}
Setter
In the setter of the dependency, we need to execute all functions that are watching this dependency (subscribers). In other words, use the notify()
method we defined earlier.
set value(newValue) {
this._value = newValue
this.notify()
}
Try it out
The implementation of dependency is done. Now it's time we try it out. To achieve that, we need to do 3 things:
- Define a dependency
- Add a function to be executed on dependency changes
- Change the value of the dependency
// Create a reactive dependency with the value of 1
const count = new Dep(1)
// Add a "watcher". This logs every change of the dependency to the console.
watchEffect(() => {
console.log('π» value changed', count.value)
})
// Change value
setTimeout(() => {
count.value++
}, 1000)
setTimeout(() => {
count.value++
}, 2000)
setTimeout(() => {
count.value++
}, 3000)
In the console log you should be able to see something like this:
π» value changed 1
π» value changed 2
π» value changed 3
π» value changed 4
You can find the complete code for the dependency on π Github.
2. Building a reactive state
This is only the first part of the puzzle and mainly necessary to understand better what is going to happen next.
To recap: We have a reactive dependency and a watch function that together give us the possibility to execute a function whenever the variable (dependency) changes. Which is already pretty damn cool. But we want to go a step further and create a state.
Instead of somthing like this:
const count = Dep(1)
const name = Dep('Marc')
id.value = 2
name.value = 'Johnny'
We want to do something like this:
const state = reactive({
count: 1,
name: 'Marc',
})
state.count = 2
state.name = 'Johnny'
To achieve this, we need to make some changes to our code:
- Add the
reactive
function. This created the "state" object. - Move getter and setter to the state instead of the dependency (because this is where the changes happen)
So the dependency (Dep
) will only serve as such. Just the dependency part, not containing any value. The values are stored in the state.
The reactive function
The reactive()
function can be seen as an initialization for the state. We pass an object to it with initial values, which is then converted to dependencies.
For each object property, the following must be done:
- Define a dependency (
Dep
) - Definer getter
- Define setter
function reactive(obj) {
Object.keys(obj).forEach((key) => {
const dep = new Dep()
let value = obj[key]
Object.defineProperty(obj, key, {
get() {
dep.depend()
return value
},
set(newValue) {
if (newValue !== value) {
value = newValue
dep.notify()
}
},
})
})
return obj
}
Changes on the dependency
Also, we need to remove the getter and setter from the dependency, since we do it now in the reactive state:
class Dep {
subscribers = new Set()
depend() {
if (activeEffect) this.subscribers.add(activeEffect)
}
notify() {
this.subscribers.forEach((sub) => sub())
}
}
The watchEffect
function stays the same.
Try out the code
And we are already done with converting our dependency variable into a reactive state. Now we can try out the code:
const state = reactive({
count: 1,
name: 'Marc',
})
watchEffect(() => {
console.log('π» state changed', state.count, state.name)
})
setTimeout(() => {
state.count++
state.name = 'Johnny'
}, 1000)
setTimeout(() => {
state.count++
}, 2000)
setTimeout(() => {
state.count++
}, 3000)
In the console log you should see something like this:
π» state changed 1 Marc
π» state changed 2 Marc
π» state changed 2 Johnny
π» state changed 3 Johnny
π» state changed 4 Johnny
You can find the complete code for the reactive state on π Github.
Summary β¨
That's it for this part of the series. We did the following:
- Create a dependency with a value inside, which notifies a subscribed function when the value changes
- Create a state where a subscribed function is called for the change of every value
Original cover photo by Joshua Earle on Unplash, edited by Marc Backes.
Top comments (2)
I think you forgot to show where
activeEffect
is defined.Is it just a simple variable that is only used in watchEffect?
Without strict mode I think this defines activeEffect in the global context. I added strict mode and just added activeEffect as a variable outside of the class and set to null.