Understanding the New Reactivity System in Vue 3
Reactivity systems are one of the key parts of modern front-end frameworks. They’re the magic wand which makes apps highly interactive, dynamic, and responsive. Understanding what a reactivity system is and how it can be applied in practice is a crucial skill for every web developer.
A reactivity system is a mechanism which automatically keeps in sync a data source (model) with a data representation (view) layer. Every time the model changes, the view is re-rendered to reflect the changes.
Let’s take a simple Markdown editor as an example. It usually has two panes: one for writing the Markdown code (which modifies the underlying model), and one for previewing the compiled HTML (which shows the updated view). When you write something in the writing pane, it’s immediately and automatically previewed in the previewing pane. Of course, this is just a simple example. Often things are far more complex.
In many cases, the data we want to display depends on some other data. In such a scenario, the dependencies are tracked and the data is updated accordingly. For example, let’s say we have a fullName
property, which depends on firstName
and lastName
properties. When any of its dependencies are modified, the fullName
property is automatically re-evaluated and the result is displayed in the view.
Now that we’ve established what reactivity is, it’s time to learn how the new Vue 3 reactivity works, and how we can use it in practice. But before we do this, we’ll take a quick look at the old Vue 2 reactivity and its caveats.
A Brief Exploration of Vue 2 Reactivity
Reactivity in Vue 2 is more or less “hidden”. Whatever we put in the data
object, Vue makes it reactive implicitly. On the one hand, this makes the developer’s job easier, but on the other hand it leads to less flexibility.
Behind the scenes, Vue 2 uses the ES5 Object.defineProperty() to convert all of the data
object’s properties into getters and setters. For each component instance, Vue creates a dependencies watcher instance. Any properties collected/tracked as dependencies during the component’s render are recorded by the watcher. Later on, when a dependency’s setter is triggered, the watcher is notified and the component re-renders and updates the view. This is basically how all the magic works. Unfortunately, there are some caveats.
Change Detection Caveats
Because of the limitations of Object.defineProperty()
, there are some data changes that Vue can’t detect. These include:
- adding/removing a property to/from an object (such as
obj.newKey = value
) - setting array items by index (such as
arr[index] = newValue
) - modifying the length of an array (such as
arr.length = newLength
)
Fortunately, to deal with these limitations Vue provides us with the Vue.set API method, which adds a property to a reactive object, ensuring the new property is also reactive and thus triggers view updates.
Let’s explore the above cases in the following example:
<div id="app">
<h1>Hello! My name is {{ person.name }}. I'm {{ person.age }} years old.</h1>
<button @click="addAgeProperty">Add "age" property</button>
<p>Here are my favorite activities:</p>
<ul>
<li v-for="item, index in activities" :key="index">
{{ item }}
<button @click="editActivity(index)">Edit</button>
</li>
</ul>
<button @click="clearActivities">Clear the activities list</button>
</div>
const App = new Vue({
el: '#app',
data: {
person: {
name: "David"
},
activities: [
"Reading books",
"Listening music",
"Watching TV"
]
},
methods: {
// 1. Add a new property to an object
addAgeProperty() {
this.person.age = 30
},
// 2. Setting an array item by index
editActivity(index) {
const newValue = prompt('Input a new value')
if (newValue) {
this.activities[index] = newValue
}
},
// 3. Modifying the length of the array
clearActivities() {
this.activities.length = 0
}
}
});
Here’s a CodePen example.
In the above example, we can see that none of the three methods is working. We can’t add a new property to the person
object. We can’t edit an item from the activities
array by using its index. And we can’t modify the length of the activities
array.
Of course, there are workarounds for these cases and we’ll explore them in the next example:
const App = new Vue({
el: '#app',
data: {
person: {
name: "David"
},
activities: [
"Reading books",
"Listening music",
"Watching TV"
]
},
methods: {
// 1. Adding a new property to the object
addAgeProperty() {
Vue.set(this.person, 'age', 30)
},
// 2. Setting an array item by index
editActivity(index) {
const newValue = prompt('Input a new value')
if (newValue) {
Vue.set(this.activities, index, newValue)
}
},
// 3. Modifying the length of the array
clearActivities() {
this.activities.splice(0)
}
}
});
Here’s a CodePen example.
In this example, we use the Vue.set
API method to add the new age
property to the person
object and to select/modify a particular item from the activities array. In the last case, we just use the JavaScript built-in splice()
array method.
As we can see, this works, but it’s a bit hacky and leads to inconsistency in the codebase. Fortunately, in Vue 3 this has been resolved. Let’s see the magic in action, in the following example:
const App = {
data() {
return {
person: {
name: "David"
},
activities: [
"Reading books",
"Listening music",
"Watching TV"
]
}
},
methods: {
// 1. Adding a new property to the object
addAgeProperty() {
this.person.age = 30
},
// 2. Setting an array item by index
editActivity(index) {
const newValue = prompt('Input a new value')
if (newValue) {
this.activities[index] = newValue
}
},
// 3. Modifying the length of the array
clearActivities() {
this.activities.length = 0
}
}
}
Vue.createApp(App).mount('#app')
Here’s a CodePen example.
In this example, which uses Vue 3, we revert to the built-in JavaScript functionality, used in the first example, and now all methods work like a charm.
In Vue 2.6, a Vue.observable() API method was introduced. It exposes, to some extent, the reactivity system allowing developers to make objects reactive explicitly. Actually, this is the exact same method Vue uses internally to wrap the data
object and is useful for creating a minimal, cross-component state store for simple scenarios. But despite its usefulness, this single method can’t match the power and flexibility of the full, feature-rich reactivity API which ships with Vue 3. And we’ll see why in the next sections.
Note: because Object.defineProperty()
is an ES5-only and un-shimmable feature, Vue 2 doesn’t support IE8 and below.
How Vue 3 Reactivity Works
The reactivity system in Vue 3 was completely rewritten in order to take advantage of the ES6 Proxy and Reflect APIs. The new version exposes a feature-rich reactivity API which makes the system far more flexible and powerful than before.
The Proxy API allows developers to intercept and modify low-level object operations on a target object. A proxy is a clone/wrapper of an object (called target) and offers special functions (called traps), which respond to specific operations and override the built-in behavior of JavaScript objects. If you still need to use the default behavior, you can use the corresponding Reflection API, whose methods, as the name suggests, reflect those of the Proxy API. Let’s explore an example to see how these APIs are used in Vue 3:
let person = {
name: "David",
age: 27
};
const handler = {
get(target, property, receiver) {
// track(target, property)
console.log(property) // output: name
return Reflect.get(target, property, receiver)
},
set(target, property, value, receiver) {
// trigger(target, property)
console.log(`${property}: ${value}`) // output: "age: 30" and "hobby: Programming"
return Reflect.set(target, property, value, receiver)
}
}
let proxy = new Proxy(person, handler);
console.log(person)
// get (reading a property value)
console.log(proxy.name) // output: David
// set (writing to a property)
proxy.age = 30;
// set (creating a new property)
proxy.hobby = "Programming";
console.log(person)
Here’s a CodePen example.
To create a new proxy, we use the new Proxy(target, handler)
constructor. It takes two arguments: the target object (person
object) and the handler object, which defines which operations will be intercepted (get
and set
operations). In the handler
object, we use the get
and set
traps to track when a property is read and when a property is modified/added. We set console statements to ensure that the methods work correctly.
The get
and set
traps take the following arguments:
target
: the target object which is wrapped by the proxyproperty
: the property namevalue
: the property value (this argument is used only for set operations)receiver
: the object on which the operation takes place (usually the proxy)
The Reflect API methods accepts the same arguments as their corresponding proxy methods. They’re used to implement the default behavior for the given operations, which for the get
trap is returning the property name and for the set
trap is returning true
if the property was set or false
if not.
The commented track()
and trigger()
functions are specific to Vue and are used to track when a property is read and when a property is modified/added. As a result, Vue re-runs the code that’s using that property.
In the last part of the example, we use a console statement to output the original person
object. Then we use another statement to read the property name
of the proxy
object. Next, we modify the age
property and create a new hobby
property. Finally, we output the person
object again to see that it has been updated correctly.
And this is how Vue 3 reactivity works in a nutshell. Of course, the real implementation is way more complex, but hopefully the example presented above is enough for you to grasp the main idea.
There’s also a couple of considerations when you use Vue 3 reactivity:
- it only works on browsers supporting ES6+
- the reactive proxy isn’t equal to the original object
Exploring the Vue 3 Reactivity API
Finally, we get to the Vue 3 reactivity API itself. In the following sections, we’ll explore the API methods divided into logical groups. I put methods in groups because I think they’re easier to remember when presented in that way. Let’s start with the basics.
Basic Methods
The first group includes the most basic methods for controlling data reactivity:
ref
takes a primitive value or a plain object and returns a reactive and mutable ref object. The ref object has only one propertyvalue
that points to the primitive value or the plain object.reactive
takes an object and returns a reactive copy of the object. The conversion is deep and affects all nested properties.readonly
takes a ref or an object (plain or reactive) and returns a readonly object to the original. The conversion is deep and affects all nested properties.markRaw
returns the object itself and prevents it from being converted to a proxy object.
Let’s now see these methods in action:
<h1>Hello, Vue 3 Reactivity API! :)</h1>
<hr>
<p><strong>Counter:</strong> {{ counter }}</p>
<button @click="counter++">+ Increment counter</button>
<br><br>
<button @click="counter--">- Decrement counter</button>
<hr>
<h3>Hello! My name is <mark>{{ person.name }}</mark>. I'm <mark>{{ person.age }}</mark> years old.</h3>
<p>Edit person's name
<input v-model="person.name" placeholder="name" /> and age
<input v-model="person.age" placeholder="age" />
</p>
<hr>
<p><strong>PI:</strong> {{ math.PI }}</p>
<button @click="math.PI = 6.28">Double PI</button> <span>(The console output after the button is clicked: <em>"Set operation on key 'PI' failed: target is readonly."</em>)</span>
<hr>
<h3>Alphabet Numbers</h3>
<table>
<tr>
<th>Letter</th>
<th>Number</th>
</tr>
<tr v-for="(value, key) in alphabetNumbers">
<td>{{ key }}</td>
<td>{{ value }}</td>
</tr>
</table>
<br>
<button @click="alphabetNumbers.B = 3">Change the value of B to 3</button><span> (Actually the letter B <em>is</em> changed to number 3 - <button @click="showValue">Show the value of B</button>, but it's <em>not</em> tracked by Vue.)</span>
import { ref, reactive, readonly, markRaw, isRef, isReactive, isReadonly, isProxy, onMounted } from 'vue';
export default {
setup () {
const counter = ref(0)
const person = reactive({
name: 'David',
age: 36
})
const math = readonly({
PI: 3.14
})
const alphabetNumbers = markRaw({
A: 1,
B: 2,
C: 3
})
const showValue = () => {
alert(`The value of B is ${alphabetNumbers.B}`)
}
onMounted(() => {
console.log(isRef(counter)) // true
console.log(isReactive(person)) // true
console.log(isReadonly(math)) // true
console.log(isProxy(alphabetNumbers)) // false
})
return {
counter,
person,
math,
alphabetNumbers,
showValue
}
}
};
See the Pen
Vue 3 Reactivity API 1 Edited by SitePoint (@SitePoint)
on CodePen.
In this example, we explore the use of the four basic reactivity methods.
First, we create a counter
ref object with a value of 0
. Then, in the view, we put two buttons which increment and decrement thecounter’s value. When we use these buttons, we see that the counter is truly reactive.
Second, we create a person
reactive object. Then, in the view, we put two input controls for editing a person’s name
and a person’s age
respectively. As we edit the person’s properties, they’re updated immediately.
Third, we create a math
readonly object. Then, in the view, we set a button for doubling the value of the math
‘s PI
property. But when we click the button, an error message is shown in the console, telling us that the object is readonly and that we can’t modify its properties.
Finally, we create an alphabetNumbers
object, which we don’t want to convert to proxy, and mark it as raw. It contains all alphabet letters with their corresponding numbers (for brevity, only the first three letters are used here). This order is unlikely to be changed, so we intentionally keep this object plain, which is good for the performance. We render the object content in a table and set a button that changes the value of B
property to 3
. We do this to show that although the object can be modified, this doesn’t lead to view re-rendering.
markRaw
is great for objects we don’t require to be reactive, such as a long list of country codes, color names and their corresponding hexadecimal numbers, and so on.
Lastly, we use the type check methods, described in the next section, to test and determine the type of each object we’ve created. We fire these checks, when the app renders initially, by using the onMounted()
lifecycle hook.
Type Check Methods
This group contains all four type checkers mentioned above:
isRef
checks if a value is a ref object.isReactive
checks if an object is a reactive proxy created byreactive
or created byreadonly
by wrapping another proxy created byreactive
.isReadonly
checks if an object is a readonly proxy created byreadonly
.isProxy
checks if an object is a proxy created byreactive
orreadonly
.
More Refs Methods
This group contains additional ref methods:
unref
returns the value of a ref.triggerRef
executes any effects tied to ashallowRef
manually.customRef
creates a customized ref with explicit control over its dependency tracking and updates triggering.
Shallow Methods
The methods in this group are “shallow” equivalents of the ref
, reactivity
, and readonly
:
shallowRef
creates a ref which tracks only itsvalue
property without making its value reactive.shallowReactive
creates a reactive proxy which tracks only its own properties excluding nested objects.shallowReadonly
creates a readonly proxy which makes only its own properties readonly excluding nested objects.
Let’s make these methods easier to understand by examining the following example:
<h1>Hello, Vue 3 Reactivity API! :)</h1>
<hr>
<h2>Shallow Ref</h2>
<p><strong>Settings:</strong> {{settings}}
<br><br>
Width: <input v-model="settings.width" />
Height: <input v-model="settings.height" />
<br><br>
<button @click="settings = { width: 80, height: 80 }">
Change the settings' value
</button>
</p>
<hr>
<h2>Shallow Reactive</h2>
<p><strong>SettingsA:</strong> {{settingsA}}
<br><br>
Width: <input v-model="settingsA.width" />
Height: <input v-model="settingsA.height" />
<br><br>
X: <input v-model="settingsA.coords.x" />
Y: <input v-model="settingsA.coords.y" />
</p>
<hr>
<h2>Shallow Readonly</h2>
<p><strong>SettingsB:</strong> {{settingsB}}
<br><br>
Width: <input v-model="settingsB.width" />
Height: <input v-model="settingsB.height" />
<br><br>
<span>(The console output after trying to change the <strong>width</strong> or <strong>height</strong> is: <em>"Set operation on key 'width/height' failed: target is readonly."</em>)</span>
<br><br>
X: <input v-model="settingsB.coords.x" />
Y: <input v-model="settingsB.coords.y" />
</p>
import {ref, shallowRef, shallowReactive, shallowReadonly, isRef, isReactive, isReadonly, onMounted } from 'vue';
export default {
setup () {
const settings = shallowRef({
width: 100,
height: 60
})
const settingsA = shallowReactive({
width: 110,
height: 70,
coords: {
x: 10,
y: 20
}
})
const settingsB = shallowReadonly({
width: 120,
height: 80,
coords: {
x: 20,
y: 40
}
})
onMounted(() => {
console.log(isReactive(settings)) // false
console.log(isReactive(settingsA)) // true
console.log(isReactive(settingsA.coords)) // false
console.log(isReadonly(settingsB)) // true
console.log(isReadonly(settingsB.coords)) // false
})
return {
settings,
settingsA,
settingsB
}
}
};
See the Pen
Vue 3 Reactivity API 2 Edited by SitePoint (@SitePoint)
on CodePen.
This example starts with the creation of a settings
shallow ref object. Then, in the view, we add two input controls to edit its width
and height
properties. But as we try to modify them, we see that they don’t update. To fix that we add a button which changes the whole object with all of its properties. Now it works. This is because the value
‘s content (width
and height
as individual properties) is not converted to a reactive object but the mutation of the value
(the object as a whole) is still tracked.
Next, we create a settingsA
shallow reactive proxy which contains the width
and height
properties and a nested coords
object with the x
and y
properties. Then, in the view, we set an input control for each property. When we modify the width
and height
properties, we see that they’re reactively updated. But when we try to modify the x
and y
properties, we see that they’re not tracked.
Lastly, we create a settingsB
shallow readonly object with the same properties as settingsA
. Here, when we try to modify the width
or height
property, an error message is shown in the console telling us that the object is readonly and we can’t modify its properties. On the other hand, the x
and y
properties can be modified without a problem.
The nested coords
object, from both of the last examples, isn’t affected by the conversion, and it’s kept plain. This means that it can be freely modified but none of its modifications will be tracked by Vue.
Conversion Methods
The next three methods are used for converting a proxy to ref(s) or a plain object:
toRef
creates a ref for a property on a source reactive object. The ref keeps the reactive connection to its source property.toRefs
converts a reactive object to a plain object. Each property of the plain object is a ref pointing to the corresponding property of the original object.toRaw
returns the raw, plain object of areactive
orreadonly
proxy.
Let’s see how these conversions works in the following example:
<h1>Hello, Vue 3 Reactivity API! :)</h1>
<hr>
<h3>Hello! My name is <mark>{{ person.name }}</mark>.
I'm <mark>{{ person.age }}</mark> years old.
My hobby is programming.</h3>
<hr>
<h2>To Ref</h2>
<p>
Name (ref): <input v-model="name" />
Person's name: <input v-model="person.name" />
</p>
<hr>
<h2>To Refs</h2>
<p>
PersonDetails' age (ref): <input v-model="personDetails.age.value" />
Person's age: <input v-model="person.age" />
</p>
<hr>
<h2>To Raw</h2>
<p>
<strong>RawPerson's hobby:</strong> {{rawPerson.hobby}}
<br><br>
RawPerson's hobby: <input v-model="rawPerson.hobby" />
</p>
import { reactive, toRef, toRefs, toRaw, isReactive, isRef, onMounted } from 'vue';
export default {
setup () {
const person = reactive({
name: 'David',
age: 30,
hobby: 'programming'
})
const name = toRef(person, 'name')
const personDetails = toRefs(person)
const rawPerson = toRaw(person)
onMounted(() => {
console.log(isRef(name)) // true
console.log(isRef(personDetails.age)) // true
console.log(isReactive(rawPerson)) // false
})
return {
person,
name,
personDetails,
rawPerson
}
}
};
See the Pen
Vue 3 Reactivity API 3 Edited by SitePoint (@SitePoint)
on CodePen.
In this example, we first create a base person
reactive object, which we’ll use as a source object.
Then we convert the person’s name
property to a ref with the same name. Then, in the view, we add two input controls — one for the name
ref and one for the person’s name
property. When we modify one of them, the other is updated accordingly so the reactive connection between them is kept.
Next, we convert all of a person’s properties to individual refs contained in the personDetails
object. Then, in the view, we add two input controls again to test one of the refs we’ve just created. As we can see, the personDetails’ age
is in complete sync with the person’s age
property, just as in the previous example.
Lastly, we convert the person
reactivity object to a rawPerson
plain object. Then, in the view, we add an input control for editing the rawPerson’s hobby
property. But as we can see, the converted object is not tracked by Vue.
Computed and Watch Methods
The last group of methods are for computing complex values and “spying” on certain value(s):
computed
takes a getter function as argument and returns an immutable reactive ref object.watchEffect
runs a function immediately and reactively tracks its dependencies and re-runs it whenever the dependencies are changed.watch
is the exact equivalent of the Options APIthis.$watch
and the correspondingwatch
option. It’s watching for a specific data source and applies side effects in a callback function when the watched source has changed.
Let’s consider the following example:
<h1>Hello, Vue 3 Reactivity API! :)</h1>
<hr>
<h3>Hello! My name is <mark>{{ fullName }}</mark>.</h3>
<p>
First Name: <input v-model="firstName" />
Last Name: <input v-model="lastName" />
</p>
<hr>
<strong>Volume:</strong> {{volume}}
<br><br>
<button @click="volume++">+ Increment volume</button>
<hr>
<strong>State:</strong> {{state}}
<br><br>
<button @click="state = state == 'playing' ? 'paused' : 'playing' ">Change state</button>
import { ref, computed, watch, watchEffect } from 'vue';
export default {
setup () {
// computed
const firstName = ref('David')
const lastName = ref('Wilson')
const fullName = computed(() => {
return firstName.value + ' ' + lastName.value
})
// watchEffect
const volume = ref(0)
watchEffect(() => {
if (volume.value != 0 && volume.value % 3 == 0) {
alert("The volume's value can be divided into 3")
}
})
// watch
const state = ref('playing')
watch(state, (newValue, oldValue) =>
alert(`The state was changed from ${oldValue} to ${newValue}`)
)
return {
firstName,
lastName,
fullName,
volume,
state
}
}
};
See the Pen
Vue 3 Reactivity API 4 Edited by SitePoint (@SitePoint)
on CodePen.
In this example, we create a fullName
computed variable which bases its computation on the firstName
and lastName
refs. Then, in the view, we add two input controls for editing the two parts of the full name. And as we can see, when we modify whichever part, the fullName
is re-calculated and the result is updated.
Next, we create a volume
ref and set a watch effect for it. Every time volume
is modified, the effect will run the callback function. To prove that, in the view, we add a button that increments the volume by one. We set a condition in the callback function that tests whether the volume’s value can be divided into 3, and when it returns true an alert message is shown. The effect is run once when the app is initiated and the volume’s value is set, and then again every time the volume’s value is modified.
Lastly, we create a state
ref and set a watch function to track it for changes. As soon as the state
changes, the callback function will be executed. In this example, we add a button that toggles the state between playing
and paused
. Every time this happens, an alert message is shown.
watchEffect
and watch
look pretty much identical in terms of functionality, but they have some distinct differences:
watchEffect
treats all reactive properties included in the callback function as dependencies. So if the callback contains three properties, they’re all tracked for changes implicitly.watch
tracks only the properties that we’ve included as arguments in the callback. Also, it provides both the previous and current value of the watched property.
As you can see, the Vue 3 reactivity API offers plenty of methods for a variety of use cases. The API is quite large, and in this tutorial I’ve only explored the basics. For a more in-depth exploration, details and edge cases, visit the Reactivity API documentation.
Conclusion
In this article, we covered what a reactivity system is and how it’s implemented in Vue 2 and Vue 3. We saw that Vue 2 has some drawbacks that are successfully resolved in Vue 3. Vue 3 reactivity is a complete rewrite based on the modern JavaScript features. Let’s summarize its advantages and disadvantages.
Advantages:
- It can be used as a standalone package. You can use it with React, for example.
- It offers much more flexibility and power thanks to its feature-rich API.
- It supports more data structures (Map, WeakMap, Set, WeakSet).
- It has better performance. Only the needed data is made reactive.
- Data manipulation caveats from Vue 2 are resolved.
Disadvantages:
- It only works on browsers supporting ES6+.
- The reactive proxy doesn’t equal to the original object in terms of identity comparison (
===
). - It requires more code compared to the Vue 2 “automatic” reactivity.
The bottom line is that Vue 3 reactivity is a flexible and powerful system, which can be used both by Vue and non-Vue developers. Whatever your case is, just grab it and start building awesome things.