Jacob's reimagining of 'Reactivity in Depth'
In my ~30 years of trying to learn things on this Earth, I've found that the phrase "back to the basics", while cliche, rings true. A few months or years after learning something and using it succesfully, I revisit those learning materials. There's always something that becomes more clear, or at least solidified, making me more confident in the skill.
Most recently, I felt it was time to go back and re-read the Vue docs on reactivity. The things that were unimportant to me while initially learning Vue — the inner mechanations of the API — had become a blind spot. To me, seeing how something works is the priority. Only afterwards can I begin to ask why it works in that way. Your brain might work differently. I certainly wish I could understand things without a fully fleshed out use case, but such is life.
I've never been able to easily learn from the technically-focused writing style of most software documentation. While reading the Reactivity in Depth page of the Vue docs, the order in which the content was presented felt quite odd to me. It's honestly got some really great information in it, but I couldn't appreciate that until I had rewritten the entire article in a way that made sense to me.
P.S. After writing this blog, I understand that it's quite the chicken & egg scenario. It's entirely possible that my explanations are no more clear than in the docs. I hope it's not a complete waste of time to read!
Reactivity
Since the invention of spreadsheets, we've all benefited from the super slick idea of reactivity. Update x over here, and now all of my cells that relied on x are magically updated! Amazing! Let's look at this a little deeper.
In the very simplest case, we might have the following:
a = b + 1
When b changes, we want the value of a to change.
let a = 0
let b = 1
a = b + 1
console.log(a) // 2
b = 2
console.log(a) // still 2
Javascript isn't doing what we want! Stick with me though.
We want a = b + 1 to run every time b is changed, but there are two major problems:
- We have no way of listening or reacting to changes to
b— a primitive of typeNumber— in Javascript - We need a way for
bto track its dependencies and notify them that it has updated
Proxies / Getters & Setters
Let's address problem #1:
We have no way of listening or reacting to changes to b (a variable) in Javascript
While there's no way to listen for changes to a primitive variable, we can listen for changes to an object's property! When creating an object, you can define a getter and setter.
So while we can't detect changes to b directly, we can have a getter/setter for b.foo or b.bar or maybe even... b.value!
Look familiar? Vue refs use the value property to address issue #1 from above. If Javascript had a way to directly hook into read/write events on a primitive variable, we wouldn't need this extra "proxy" layer. Instead, we must read and write from the ugly b.value. At least now you know why!
We'll go into exactly how ref works in the next section, but for now we know that changes can be detected. This helps us accomplish our goal of running a = b + 1 every time b updates.
Note: Under the hood, reactive and ref are implemented differently for performance reasons, but the idea is functionally the same.
Dependencies
Problem #2:
We need a way for b to track its dependencies and notify them that it has updated
This is addressed in a few different ways by Vue. The crowd favorite is computed, but watchEffect is used in a more straightforward manner that will help with our explanation. You can use it in the following way:
const a = ref(0)
const b = ref(1)
watchEffect(() => {
a.value = b.value + 1
})
a and b, because they are now wrapped in a proxy thanks to ref, "know" when they are being written to and read from. In psuedocode, a ref is essentially the following:
function ref(value) {
// wrap our value in a proxy object so we can use getter/setter
const refObject = {
get value() {
track(refObject, 'value')
return value // default get behavior
},
set value(newValue) {
value = newValue // default set behavior
trigger(refObject, 'value')
}
}
return refObject
}
The main takeaway here is that our ref has a getter and setter for the property value. In addition to either returning the value or setting the value, they each do one other thing: track and trigger.
Before we dive into those, it's helpful to reflect that a ref, by itself, does not facilitate reactivity. It must be paired with a reactive effect — the part of the program that links the dependencies and their calculations. In our example above, the facilitator of the reactive effect is watchEffect.
Track
Track helps catalog the dependencies of our variable. It does this by checking to see if any reactive effects are occurring while it's value is being read.
let activeEffect // global scope read by track() and written to by our effect
function track() {
if (activeEffect) {
const effects = getSubscribersForProperty(target, key)
effects.add(activeEffect)
}
}
There will not be any reactive effects occuring until we create a dependency relationship via our watchEffect. With this very basic understanding of dependency tracking, let's move on.
Trigger
Trigger invokes any "tracked" effects that were collected when it was read.
function trigger(target, key) {
const effects = getSubscribersForProperty(target, key)
effects.forEach((effect) => effect())
}
In this case, one of the effect() calls would be a.value = b.value + 1, and a will have been magically — eh... reactively updated!
With this basic understanding of triggering, let's move on.
watchEffect
It is difficult to appreciate the mechanics of ref without the watchEffect that utilizes it. Hopefully things become more clear now that we have all the pieces.
In basic terms, watchEffect puts a reactive effect into motion by attaching the computation of a to the getter and setter of b. The behavior of watchEffect can be distilled to the following:
let activeEffect // global scope
function watchEffect(computeValueOfA) {
const effect = () => {
activeEffect = effect
computeValueOfA()
activeEffect = null
}
effect()
}
The name effect is used because the function alters the state of the program; it changes the global variable activeEffect.
I think the simplest way to understand this code is to walk through the full process step by step while incorporating all of the code we've covered thus far.
let activeEffect // global scope
function ref(value) {
const refObject = {
get value() {
track(refObject, 'value')
return value
},
set value(newValue) {
value = newValue
trigger(refObject, 'value')
}
}
return refObject
}
function track() {
if (activeEffect) {
const effects = getSubscribersForProperty(target, key)
effects.add(activeEffect)
}
}
function trigger(target, key) {
const effects = getSubscribersForProperty(target, key)
effects.forEach((effect) => effect())
}
function watchEffect(computeValueOfA) {
const effect = () => {
activeEffect = effect
computeValueOfA()
activeEffect = null
}
effect()
}
watchEffectsetsactiveEffectto the effect that containscomputeValueOfA()computeValueOfA(), which is equal toa.value = b.value + 1is invoked- The
b.valuein this expression is read, which invokes the track() function of its getter - The value of
activeEffectis added to the set of effects that are dependent onb.value a.valueis written to, but it has no dependencies, so nothing happenswatchEffectnulls outactiveEffect
In steps 1-5, we've created a reactive relationship between a and b, where b is now aware of the calculation that will need to be run anytime its own value changes. The following example will demonstrate that.
b.valueis written to, meaning the trigger() function is called- Any effects that were captured while
b.valuewas being read are now invoked - The effect previously created by
watchEffectsetsactiveEffectto itself computeValueOfA(), which is equal toa.value = b.value + 1is invokedb.valueis read, which means it calls track()- The current
activeEffectis not added to the set of subscriber effects because it's already there a.valueis written to, but it has no dependencies, so nothing happens- The effect nulls out
activeEffect
That's it! That's everything involved in reading and writing to reactive variables. While we didn't cover reactive, watch, computed, or template refs in detail, the core functionality is the same. For example, computed is essentially just watchEffect, but the ref is created for you. reactive is essentially a ref, but because you're only using objects with reactive, the extra .value layer isn't necessary because it's already an object!
Closing thoughts
It often makes sense to try out a new tool or skill before you really commit to learning it. Learning is often a painful, iterative process. Spend some time using the tool, and once you've got a hang of it, come back to basics. You'll have a structure in place and you can fill in the gaps you missed.
← Back to blog