Skip to main content

Understanding the New Reactivity System in Vue 3

By Ivaylo Gerchev

JavaScript

Share:

Free JavaScript Book!

Write powerful, clean and maintainable JavaScript.

RRP $11.95

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 proxy
  • property: the property name
  • value: 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 property value 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 by reactive or created by readonly by wrapping another proxy created by reactive.
  • isReadonly checks if an object is a readonly proxy created by readonly.
  • isProxy checks if an object is a proxy created by reactive or readonly.

More Refs Methods

This group contains additional ref methods:

  • unref returns the value of a ref.
  • triggerRef executes any effects tied to a shallowRef 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 its value 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 a reactive or readonly 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 API this.$watch and the corresponding watch 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.

Ivaylo Gerchev is a self-taught web developer/designer. He loves to play with HTML, CSS, jQuery, PHP, and WordPress, as well as Photoshop and Illustrator. Ivaylo's motto is "Minimum effort for maximum effect!"

New books out now!

Learn the basics fo programming with the web's most popular language - JavaScript


A practical guide to leading radical innovation and growth.

Integromat Tower Ad