Introduction
Web development has always been about creating dynamic experiences. One of the biggest challenges developers face is managing how data changes over time and reflecting these changes in the UI promptly and accurately. This is where Vue.js, one of the most popular JavaScript frameworks, excels with its powerful reactive data system.
In this article, we dig into the heart of Vue's reactivity system. We unravel how it perfectly syncs your application UI with the underlying data state, allowing for a seamless user experience. Whether new to Vue or looking to deepen your understanding, this guide will provide a clear and concise overview of Vue's reactivity, empowering you to build more efficient and responsive Vue 3 applications. So, let’s kick off and embark on this journey to decode Vue's reactive data system.
What is Vue's Reactive Data?
What does it mean for data to be ”'reactive”? In essence, when data is reactive, it means that every time the data changes, all parts of the UI that rely on this data automatically update to reflect these changes. This ensures that the user is always looking at the most current state of the application.
At its core, Vue's Reactive Data is like a superpower for your application data. Think of it like a mirror - whatever changes you make in your data, the user interface (UI) reflects these changes instantly, like a mirror reflecting your image. This automatic update feature is what we refer to as “reactivity”.
To visualize this concept, let's use an example of a simple Vue application displaying a message on the screen:
import { createApp, reactive } from 'vue';
const app = createApp({
setup() {
const state = reactive({
message: 'Hello Vue!'
});
return {
state
};
}
});
app.mount('#app');
In this application, 'message' is a piece of data that says 'Hello Vue!'. Let's say you change this message to 'Goodbye Vue!' later in your code, like when a button is clicked.
state.message = 'Goodbye Vue!';
With Vue's reactivity, when you change your data, the UI automatically updates to 'Goodbye Vue!' instead of 'Hello Vue!'. You don't have to write extra code to make this update happen - Vue's Reactive Data system takes care of it.
How does it work?
Let's keep the mirror example going. Vue's Reactive Data is the mirror that reflects your data changes in the UI. But how does this mirror know when and what to reflect? That's where Vue's underlying mechanism comes into play.
Vue has a behind-the-scenes mechanism that helps it stay alerted to any changes in your data. When you create a reactive data object, Vue doesn't just leave it as it is. Instead, it sends this data object through a transformation process and wraps it up in a Proxy.
Proxy objects are powerful and can detect when a property is changed, updated, or deleted.
Let's use our previous example:
import { createApp, reactive } from 'vue';
const app = createApp({
setup() {
const state = reactive({
message: 'Hello Vue!'
});
return {
state
};
}
});
app.mount('#app');
Consider our “message” data as a book in a library. Vue places this book (our data) within a special book cover (the Proxy). This book cover is unique - it's embedded with a tracking device that notifies Vue every time someone reads the book (accesses the data) or annotates a page (changes the data).
In our example, the reactive function creates a Proxy object that wraps around our state object.
When you change the 'message':
state.message = 'Goodbye Vue!';
The Proxy notices this (like a built-in alarm going off) and alerts Vue that something has changed. Vue then updates the UI to reflect this change.
Let’s look deeper into what Vue is doing for us and how it transforms our object into a Proxy object. You don't have to worry about creating or managing the Proxy; Vue handles everything.
const state = reactive({
message: 'Hello Vue!'
});
// What vue is doing behind the scenes:
function reactive(obj) {
return new Proxy(obj, {
// target = state and key = message
get(target, key) {
track(target, key)
return target[key]
},
set(target, key, value) {
target[key] = value
// Here Vue will trigger its reactivity system to update the DOM.
trigger(target, key)
}
})
}
In the example above, we encapsulate our object, in this case, “state”, converting it into a Proxy object. Note that within the second argument of the Proxy, we have two methods: a getter and a setter.
The getter method is straightforward: it merely returns the value, which in this instance is “state.message” equating to 'Hello Vue!'
Meanwhile, the setter method comes into play when a new value is assigned, as in the case of “state.message = ‘Hey young padawan!’”. Here, “value” becomes our new 'Hey young padawan!', prompting the property to update. This action, in turn, triggers the reactivity system, which subsequently updates the DOM.
Venturing Further into the Depths
If you have been paying attention to our examples above, you might have noticed that inside the Proxy
method, we call the functions track
and trigger
to run our reactivity. Let’s try to understand a bit more about them. You see, Vue 3 reactivity data is more about Proxy objects. Let’s create a new example:
<script setup>
import { reactive, watch, computed, effect } from "vue";
const state = reactive({
showSword: false,
message: "Hey young padawn!",
});
function changeMessage() {
state.message = "It's dangerous to go alone! Take this.";
}
effect(() => {
if (state.message === "It's dangerous to go alone! Take this.") {
state.showSword = true;
}
});
</script>
<template>
<p>
{{ state.message }}
</p>
<p v-if="state.showSword">
<img src="./assets/sword.png" alt="sword" />
</p>
<button @click="changeMessage">Click!</button>
</template>
In this example, when you click on the button, the message's value changes. This change triggers the effect function to run, as it's actively listening for any changes in its dependencies.
How does the effect
property know when to be called?
Vue 3 has three main functions to run our reactivity: effect
, track
, and trigger
.
The effect
function is like our supervisor. It steps in and takes action when our data changes – similar to our effect method, we will dive in more later.
Next, we have the track
function. It notes down all the important data we need to keep an eye on. In our case, this data would be state.message
.
Lastly, we've got the trigger
function. This one is like our alarm bell. It alerts the effect
function whenever our important data (the stuff track
is keeping an eye on) changes.
In this way, trigger
, track
, and effect
work together to keep our Vue application reacting smoothly to changes in data.
Let’s go back to them:
function reactive(obj) {
return new Proxy(obj, {
get(target, key) {
// target = state & key = message
track(target, key) // keep an eye for this
return target[key]
},
set(target, key, value) {
target[key] = value
trigger(target, key) // trigger the effects!
}
})
}
Tracking (Dependency Collection)
Tracking is the process of registering dependencies between reactive objects and the effects that depend on them. When a reactive property is read, it's "tracked" as a dependency of the current running effect.
When we execute track()
, we essentially store our effects in a Set object. But what exactly is an "effect"? If we revisit our previous example, we see that the effect method must be run whenever any property changes. This action — running the effect method in response to property changes — is what we refer to as an "Effect"! (computed property, watcher, etc.)
Note: We'll outline a basic, high-level overview of what might happen under the hood. Please note that the actual implementation is more complex and optimized, but this should give you an idea of how it works.
Let’s see how it works! In our example, we have the following reactive object:
const state = reactive({
showSword: false,
message: "Hey young padawn!",
});
// which is transformed under the hood to:
function reactive(obj) {
return new Proxy(obj, {
get(target, key) {
// target = state | key = message
track(target, key) // keep an eye for this
return target[key]
},
set(target, key, value) {
target[key] = value
trigger(target, key) // trigger the effects!
}
})
}
We need a way to reference the reactive object with its effects. For that, we use a WeakMap. Which type is going to look something like this:
WeakMap<target, Map<key, Set<effect>>>
We are using a WeakMap to set our object state as the target (or key). In the Vue code, they call this object targetMap
.
Within this targetMap
object, our value is an object named depMap
of Map type. Here, the keys represent our properties (in our case, that would be message
and showSword
), and the values correspond to their effects – remember, they are stored in a Set that in Vue 3 we refer to as dep
.
Huh… It might seem a bit complex, right? Let's make it more straightforward with a visual example:
With the above explained, let’s see what this Track
method kind of looks like and how it uses this targetMap
. This method essentially is doing something like this:
let activeEffect; // we will see more of this later
function track(target, key) {
if (activeEffect) {
// `depsMap` maps targets to their keys and dependent effects
let depsMap = targetMap.get(target);
// If we don't have a depsMap for this target in our `targetMap`, create one.
if (!depsMap) {
depsMap = new Map();
targetMap.set(target, depsMap);
}
let dep = depsMap.get(key);
if (!dep) {
// If we don't have a set of effects for this key in our `depsMap`, create one.
dep = new Set();
depsMap.set(key, dep);
}
// Add the current effect as a dependency
dep.add(activeEffect);
}
}
At this point, you have to be wondering, how does Vue 3 know what activeEffect
should run?
Vue 3 keeps track of the currently running effect by using a global variable. When an effect is executed, Vue temporarily stores a reference to it in this global variable, allowing the track
function to access the currently running effect and associate it with the accessed reactive property. This global variable is called inside Vue as activeEffect
.
Vue 3 knows which effect is assigned to this global variable by wrapping the effects functions in a method that invokes the effect whenever a dependency changes. And yes, you guessed, that method is our effect
method.
effect(() => {
if (state.message === "It's dangerous to go alone! Take this.") {
state.showSword = true;
}
});
This method behind the scenes is doing something similar to this:
function effect(update) { //the function we are passing in
const effectMethod = () => {
// Assign the effect as our `activeEffect`
activeEffect = effectMethod
// Runs the actual method, also triggering the `get` trap inside our proxy
update();
// Clean the activeEffect after our Effect has finished
activeEffect = null
}
effectMethod()
}
The handling of activeEffect
within Vue's reactivity system is a dance of careful timing, scoping, and context preservation. Let’s go step by step on how this is working all together. When we run our Effect
method for the first time, we call the get
trap of the Proxy.
function effect(update)
const effectMethod = () => {
// Storing our active effect
activeEffect = effectMethod
// Running the effect
update()
...
}
...
}
effect(() => {
// we call the the `get` trap when getting our `state.message`
if (state.message === "It's dangerous to go alone! Take this.") {
state.showSword = true;
}
});
When running the get
trap, we have our activeEffect
so we can store it as a dependency.
function reactive(obj) {
return new Proxy(obj, {
// Gets called when our effect runs
get(target, key) {
track(target, key) // Saves the effect
return target[key]
},
// ... (other handlers)
})
}
function track(target, key) {
if (activeEffect) {
//... rest of the code
// Add the current effect as a dependency
dep.add(activeEffect);
}
}
This coordination ensures that when a reactive property is accessed within an effect, the track function knows which effect is responsible for that access.
Trigger Method
Our last method makes this Reactive system to be complete. The trigger
method looks up the dependencies for the given target and key and re-runs all dependent effects.
function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) return; // no dependencies, no effects, no need to do anything
const dep = depsMap.get(key);
if (!dep) return; // no dependencies for this key, no need to do anything
// all dependent effects to be re-run
dep.forEach(effect => {
effect()
});
}
Conclusion
Diving into Vue 3's reactivity system has been like unlocking a hidden superpower in my web development toolkit, and honestly, I've had a blast learning about it. From the rudimentary elements of reactive data and instantaneous UI updates to the intricate details involving Proxies, track and trigger functions, and effects, Vue 3's reactivity is an impressively robust framework for building dynamic and responsive applications.
In our journey through Vue 3's reactivity, we've uncovered how this framework ensures real-time and precise updates to the UI. We've delved into the use of Proxies to intercept and monitor variable changes and dissected the roles of track and trigger functions, along with the 'effect' method, in facilitating seamless UI updates. Along the way, we've also discovered how Vue ingeniously manages data dependencies through sophisticated data structures like WeakMaps and Sets, offering us a glimpse into its efficient approach to change detection and UI rendering.
Whether you're just starting with Vue 3 or an experienced developer looking to level up, understanding this reactivity system is a game-changer. It doesn't just streamline the development process; it enables you to create more interactive, scalable, and maintainable applications. I love Vue 3, and mastering its reactivity system has been enlightening and fun. Thanks for reading, and as always, happy coding!