DEV Community

Jinjiang
Jinjiang

Posted on

Understanding Reactivity in Vue 3.0

This article is just written for my knowledge and understanding of the coolest part in Vue: the reactivity system.

Background

As we know, Vue.js team is working on 3.0 for a while. Recently it released the first Beta version. That means the core tech design is stable enough. Now I think it's time to walk through something inside Vue 3.0. That's one of my most favorite parts: the reactivity system.

What's reactivity?

For short, reactivity means, the result of calculations, which depends on some certain data, will be automatically updated, when the data changes.

In modern web development, we always need to render some data-related or state-related views. So obviously, making data reactive could give us lots of benefits. In Vue, the reactivity system always exists from its very early version till now. And I think that's one of the biggest reasons why Vue is so popular.

Let's have a look at the reactivity system in the early version of Vue first.

Reactivity in Vue from 0.x to 1.x

The first time I touched Vue is about 2014, I guess it was Vue 0.10. At that time, you could just pass a plain JavaScript object into a Vue component through data option. Then you could use them in a piece of document fragment as its template with reactivity. Once the data changes, the view would be automatically updated. Also you could use computed and watch options to benefit yourself from the reactivity system in more flexible ways. Same to the later Vue 1.x.

new Vue({
  el: '#app',
  template: '<div @click="x++">{{x}} + {{y}} = {{z}}</div>',
  data() {
    return { x: 1, y: 2 }
  },
  computed: {
    z() { return this.x + this.y }
  },
  watch: {
    x(newValue, oldValue) {
      console.log(`x is changed from ${oldValue} to ${newValue}`)
    }
  }
})
Enter fullscreen mode Exit fullscreen mode

You may found these APIs didn't change too much so far. Because they work the same totally.

So how does it work? How to make a plain JavaScript object reactive automatically?

Fortunately, in JavaScript we have an API Object.defineProperty() which could overwrite the getter/setter of an object property. So to make them reactive, there could be 3 steps:

  1. Use Object.defineProperty() to overwrite getters/setters of all the properties inside a data object recursively. Besides behaving normally, it additionally injects a trigger inside all setters, and a tracker inside all getters. Also it will create a small Dep instance inside each time to record all the calculations which depend on this property.
  2. Every time we set a value into a property, it will call the setter, which will re-evaluate those related calculations inside the Dep instance. Then you may ask how could we record all the related calculations. The fact is when each time we define a calculation like a watch function or a DOM update function, it would run once first - sometimes it runs as the initialization, sometimes it's just a dry-run. And during that running, it will touch every tracker inside the getters it depends on. Each tracker will push the current calculation function into the corresponding Dep instance.
  3. So next time when some data changes, it will find out all related calculations inside the corresponding Dep instance, and then run them again. So the effect of these calculations will be updated automatically.

A simple implementation to observe data using Object.defineProperty is like:

// data
const data = { x: 1, y: 2 }

// real data and deps behind
let realX = data.x
let realY = data.y
const realDepsX = []
const realDepsY = []

// make it reactive
Object.defineProperty(data, 'x', {
  get() {
    trackX()
    return realX
  },
  set(v) {
    realX = v
    triggerX()
  }
})
Object.defineProperty(data, 'y', {
  get() {
    trackY()
    return realY
  },
  set(v) {
    realY = v
    triggerY()
  }
})

// track and trigger a property
const trackX = () => {
  if (isDryRun && currentDep) {
    realDepsX.push(currentDep)
  }
}
const trackY = () => {
  if (isDryRun && currentDep) {
    realDepsY.push(currentDep)
  }
}
const triggerX = () => {
  realDepsX.forEach(dep => dep())
}
const triggerY = () => {
  realDepsY.forEach(dep => dep())
}

// observe a function
let isDryRun = false
let currentDep = null
const observe = fn => {
  isDryRun = true
  currentDep = fn
  fn()
  currentDep = null
  isDryRun = false
}

// define 3 functions
const depA = () => console.log(`x = ${data.x}`)
const depB = () => console.log(`y = ${data.y}`)
const depC = () => console.log(`x + y = ${data.x + data.y}`)

// dry-run all dependents
observe(depA)
observe(depB)
observe(depC)
// output: x = 1, y = 2, x + y = 3

// mutate data
data.x = 3
// output: x = 3, x + y = 5
data.y = 4
// output: y = 4, x + y = 7
Enter fullscreen mode Exit fullscreen mode

Inside Vue 2.x and earlier, the mechanism roughly like this above, but much better abstracted, designed, and implemented.

For supporting more complex cases like arrays, nested properties, or mutating more than 2 properties at the same time, there are more implementation and optimization details inside Vue, but basically, the same mechanism to we mentioned before.

Reactivity in Vue 2.x

From 1.x to 2.x, it was a total rewrite. And it introduced some really cool features like virtual DOM, server-side rendering, low-level render functions, etc. But the interesting thing is the reactivity system didn't change too much, however, the usage above was totally different:

  1. From 0.x to 1.x, the rendering logic depends on maintaining a document fragment. Inside that document fragment, there are some DOM update functions for each dynamic element, attribute, and text content. So the reactivity system mostly works between the data object and these DOM update functions. Since the functions all real DOM functions so the performance is not quite good. In Vue 2.x, this rendering logic of a Vue component became a whole pure JavaScript render function. So it would firstly return virtual nodes instead of real DOM nodes. Then it would update the real DOM based on the result of a fast mutation diff algorithm for the virtual DOM nodes. It was faster than before.
  2. In Vue 2.6, it introduced a standalone API Vue.observalue(obj) to generate reactive plain JavaScript objects. So you could use them inside a render function or a computed property. It was more flexible to use.

At the same time, there are some discussions in Vue community about abstracting the reactivity system into an independent package for wider usage. However it didn't happen at that time.

Limitation of the reactivity system before 3.0

So far, Vue didn't change the reactivity mechanism. But it doesn't mean the current solution is ideally perfect. As I personally understand, there are some caveats:

  • Because of the limitation of Object.defineProperty we couldn't observe some data changes like:
    1. Setting array items by assigning value to a certain index. (e.g. arr[0] = value)
    2. Setting the length of an array. (e.g. arr.length = 0)
    3. Adding a new property to an object. (e.g. obj.newKey = value) So it needs some complementary APIs like Vue.$set(obj, newKey, value).
  • Because of the limitation of plain JavaScript data structure, for each reactive object there would be an unenumerable property named __ob__, which might lead to conflict in some extreme cases.
  • It didn't support more data types like Map and Set. Neither other non-plain JavaScript objects.
  • The performance is an issue. When the data is large, making it reactive when the initialization would cost visible time. There are some tips to flatten the initial cost but a little bit tricky.

Reactivity system in Vue 3.0

For short, in Vue 3.0, the reactivity system was totally rewritten with a new mechanism and new abstraction, as an independent package. And it also supports more modern JavaScript data types.

You may be familiar with it, maybe not. No worry. Let's quickly take a look at it first by creating a Vue 3.0 project.

Create a Vue 3.0 project

Until now, there is no stable full-featured project generator, since it's still in Beta. We could try Vue 3.0 through an experimental project named "vite":

GitHub logo vitejs / vite

Next generation frontend tooling. It's fast!

Vite logo


npm package node compatibility build status Start new PR in StackBlitz Codeflow discord chat


Vite

Next Generation Frontend Tooling

  • 💡 Instant Server Start
  • ⚡️ Lightning Fast HMR
  • 🛠️ Rich Features
  • 📦 Optimized Build
  • 🔩 Universal Plugin Interface
  • 🔑 Fully Typed APIs

Vite (French word for "quick", pronounced /vit/, like "veet") is a new breed of frontend build tooling that significantly improves the frontend development experience. It consists of two major parts:

In addition, Vite is highly extensible via its Plugin API and JavaScript API with full typing support.

Read the Docs to Learn More.

Packages

Package Version (click for changelogs)
vite vite version
@vitejs/plugin-legacy plugin-legacy version
create-vite create-vite version

Contribution

See Contributing Guide.

License

MIT.

Sponsors

sponsors

https://github.com/vitejs/vite

Just run these commands below:

$ npx create-vite-app hello-world
$ cd hello-world
$ npm install
$ npm run dev
Enter fullscreen mode Exit fullscreen mode

Then you could access your Vue 3.0 app through http://localhost:3000.

You could see there is already a Vue component App.vue:

<template>
  <p>
    <span>Count is: {{ count }}</span>
    <button @click="count++">increment</button>
    is positive: {{ isPositive }} 
  </p>
</template>

<script>
export default {
  data: () => ({ count: 0 }),
  computed: {
    isPositive() { return this.count > 0 } 
  }
}
</script>
Enter fullscreen mode Exit fullscreen mode

There is a reactive property count and it's displayed in the <template>. When users click the "increment" button, the property count would be incremented, the computed property isPositive would be re-calculated too, and the UI would be updated automatically.

It seems nothing different to the former version so far.

Now let's try something impossible in early versions of Vue.

1. Adding new property

As we mentioned, in Vue 2.x and earlier, we couldn't observe newly added property automatically. For example:

<template>
  <p>
    <span>My name is {{ name.given }} {{ name.family }}</span>
    <button @click="update">update name</button>
  </p>
</template>

<script>
export default {
  data: () => ({
    name: {
      given: 'Jinjiang'
    }
  }),
  methods: {
    update() {
      this.name.family = 'Zhao'
    }
  }
}
</script>
Enter fullscreen mode Exit fullscreen mode

The update method couldn't work properly because the new property family couldn't be observed. So when adding this new property, the render function won't be re-calculated. If you want this work, you should manually use another complementary API as Vue.$set(this.name, 'family', 'Zhao').

But in Vue 3.0, it already works as well. You don't need Vue.$set anymore.

2. Assigning items to an array by index

Now let's try to set a value into an index of an array:

<template>
  <ul>
    <li v-for="item, index in list" :key="index">
      {{ item }}
      <button @click="edit(index)">edit</button>
    </li>
  </ul>
</template>

<script>
export default {
  data() {
    return {
      list: [
        'Client meeting',
        'Plan webinar',
        'Email newsletter'
      ]
    }
  },
  methods: {
    edit(index) {
      const newItem = prompt('Input a new item')
      if (newItem) {
        this.list[index] = newItem
      }
    }
  }
}
</script>
Enter fullscreen mode Exit fullscreen mode

In Vue 2.x and earlier, when you click one of the "edit" buttons in the list item and input a new piece of a text string, the view won't be changed, because setting item with an index like this.list[index] = newItem couldn't be tracked. You should write Vue.$set(this.list, index, newItem) instead. But in Vue 3.0, it works, too.

3. Setting the length property of an array

Also if we add another button to the example above to clean all items:

<template>
  <ul>...</ul>
  <!-- btw Vue 3.0 supports multi-root template like this -->
  <button @click="clean">clean</button>
</template>

<script>
export default {
  data: ...,
  methods: {
    ...,
    clean() { this.list.length = 0 }
  }
}
</script>
Enter fullscreen mode Exit fullscreen mode

it won't work in Vue 2.x and earlier, because setting the length of an array like this.list.length = 0 couldn't be tracked. So you have to use other methods like this.list = []. But in Vue 3.0, all the ways above works.

4. Using ES Set/Map

Let's see a similar example with ES Set:

<template>
  <div>
    <ul>
      <li v-for="item, index in list" :key="index">
        {{ item }}
        <button @click="remove(item)">remove</button>
      </li>
    </ul>
    <button @click="add">add</button>
    <button @click="clean">clean</button>
  </div>
</template>

<script>
export default {
  data: () => ({
    list: new Set([
      'Client meeting',
      'Plan webinar',
      'Email newsletter'
    ])
  }),
  created() {
    console.log(this.list)
  },
  methods: {
    remove(item) {
      this.list.delete(item)
    },
    add() {
      const newItem = prompt('Input a new item')
      if (newItem) {
        this.list.add(newItem)
      }
    },
    clean() {
      this.list.clear()
    }
  }
}
</script>
Enter fullscreen mode Exit fullscreen mode

Now we use a Set instead of an array. In Vue 2.x and earlier, fortunately it could be rendered properly for the first time. But when you remove, add, or clear, the view won't be updated, because they are not tracked. So usually we don't use Set or Map in Vue 2.x and earlier. In Vue 3.0, the same code would work as you like, because it totally supports them.

5. Using non-reactive properties

If we have some one-time consuming heavy data in a Vue component, probably it doesn't need to be reactive, because once initialized, it won't change. But in Vue 2.x and earlier, whatever you use them again, all the properties inside will be tracked. So sometimes it costs visible time. Practically, we have some other ways to walk-around but it's a little bit tricky.

In Vue 3.0, it provides a dedicated API to do this - markRaw:

<template>
  <div>
    Hello {{ test.name }}
    <button @click="update">should not update</button>
  </div>
</template>

<script>
import { markRaw } from 'vue'
export default {
  data: () => ({
    test: markRaw({ name: 'Vue' })
  }),
  methods: {
    update(){
      this.test.name = 'Jinjiang'
      console.log(this.test)
    }
  }
}
</script>
Enter fullscreen mode Exit fullscreen mode

In this case, we use markRaw to tell the reactivity system, the property test and its descendants properties don't need to be tracked. So the tracking process would be skipped. At the same time, any further update on them won't trigger a re-render.

Additionally, there is another "twin" API - readonly. This API could prevent data to be mutated. For example:

import { readonly } from 'vue'

export default {
  data: () => ({
    test: readonly({ name: 'Vue' })
  }),
  methods: {
    update(){
      this.test.name = 'Jinjiang'
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Then the mutation to this.test would be failed.

So far we see the power and magic of the reactivity system in Vue 3.0. Actually there are more powerful ways to use it. But we won't move on immediately, because before mastering them, it's also great to know how it works behind Vue 3.0.

How it works

For short, the reactivity system in Vue 3.0 suits up with ES2015!

First part: simple data observer

Since ES2015, there are a pair of APIs - Proxy and Reflect. They are born to reactivity systems! Vue 3.0 reactivity system just be built based on that.

With Proxy you could set a "trap" to observe any operation on a certain JavaScript object.

const data = { x: 1, y: 2 }

// all behaviors of a proxy by operation types
const handlers = {
  get(data, propName, proxy) {
    console.log(`Get ${propName}: ${data[propName]}!`)
    return data[propName]
  },
  has(data, propName) { ... },
  set(data, propName, value, proxy) { ... },
  deleteProperty(data, propName) { ... },
  // ...
}

// create a proxy object for the data
const proxy = new Proxy(data, handlers)

// print: 'Get x: 1' and return `1`
proxy.x
Enter fullscreen mode Exit fullscreen mode

With Reflect you could behave the same as the original object.

const data = { x: 1, y: 2 }

// all behaviors of a proxy by operation types
const handlers = {
  get(data, propName, proxy) {
    console.log(`Get ${propName}: ${data[propName]}!`)
    // same behavior as before
    return Reflect.get(data, propName, proxy)
  },
  has(...args) { return Reflect.set(...args) },
  set(...args) { return Reflect.set(...args) },
  deleteProperty(...args) { return Reflect.set(...args) },
  // ...
}

// create a proxy object for the data
const proxy = new Proxy(data, handlers)

// print: 'Get x: 1' and return `1`
proxy.x
Enter fullscreen mode Exit fullscreen mode

So with Proxy + Reflect together, we could easily make a JavaScript object observable, and then, reactive.

const track = (...args) => console.log('track', ...args)

const trigger = (...args) => console.log('trigger', ...args)

// all behaviors of a proxy by operation types
const handlers = {
  get(...args) { track('get', ...args); return Reflect.get(...args) },
  has(...args) { track('has', ...args); return Reflect.set(...args) },
  set(...args) { Reflect.set(...args); trigger('set', ...args) },
  deleteProperty(...args) {
    Reflect.set(...args);
    trigger('delete', ...args)
  },
  // ...
}

// create a proxy object for the data
const data = { x: 1, y: 2 }
const proxy = new Proxy(data, handlers)

// will call `trigger()` in `set()`
proxy.z = 3

// create a proxy object for an array
const arr = [1,2,3]
const arrProxy = new Proxy(arr, handlers)

// will call `track()` & `trigger()` when get/set by index
arrProxy[0]
arrProxy[1] = 4

// will call `trigger()` when set `length`
arrProxy.length = 0
Enter fullscreen mode Exit fullscreen mode

So this observer is better than Object.defineProperty because it could observe every former dead angle. Also the observer just needs to set up a "trap" to an object. So less cost during the initialization.

And it's not all the implementation, because in Proxy it could handle ALL kinds of behaviors with different purposes. So the completed code of handlers in Vue 3.0 is more complex.

For example if we run arrProxy.push(10), the proxy would trigger a set handler with 3 as its propName and 10 as its value. But we don't literally know whether or not it's a new index. So if we would like to track arrProxy.length, we should do more precise determination about whether a set or a deleteProperty operation would change the length.

Also this Proxy + Reflect mechanism supports you to track and trigger mutations in a Set or a Map. That means operations like:

const map = new Map()
map.has('x')
map.get('x')
map.set('x', 1)
map.delete('x')
Enter fullscreen mode Exit fullscreen mode

would also be observable.

Second: more reactivity APIs

In Vue 3.0, we also provide some other APIs like readonly and markRaw. For readonly what you need is just change the handlers like set and deleteProperty to avoid mutations. Probably like:

const track = (...args) => console.log('track', ...args)
const trigger = (...args) => console.log('trigger', ...args)

// all behaviors of a proxy by operation types
const handlers = {
  get(...args) { track('get', ...args); return Reflect.get(...args) },
  has(...args) { track('has', ...args); return Reflect.set(...args) },
  set(...args) {
    console.warn('This is a readonly proxy, you couldn\'t modify it.')
  },
  deleteProperty(...args) {
    console.warn('This is a readonly proxy, you couldn\'t modify it.')
  },
  // ...
}

// create a proxy object for the data
const data = { x: 1, y: 2 }
const readonly = new Proxy(data, handlers)

// will warn that you couldn't modify it
readonly.z = 3

// will warn that you couldn't modify it
delete readonly.x
Enter fullscreen mode Exit fullscreen mode

For markRaw, in Vue 3.0 it would set a unenumerable flag property named __v_skip. So when we are creating a proxy for data, if there is a __v_skip flag property, then it would be skipped. Probably like:

// track, trigger, reactive handlers
const track = (...args) => console.log('track', ...args)
const trigger = (...args) => console.log('trigger', ...args)
const reactiveHandlers = { ... }

// set an invisible skip flag to raw data
const markRaw = data => Object.defineProperty(
  data,
  '__v_skip',
  { value: true }
)

// create a proxy only when there is no skip flag on the data
const reactive = data => {
  if (data.__v_skip) {
    return data
  }
  return new Proxy(data, reactiveHandlers)
}

// create a proxy object for the data
const data = { x: 1, y: 2 }
const rawData = markRaw(data)
const reactiveData = readonly(data)
console.log(rawData === data) // true
console.log(reactiveData === data) // true
Enter fullscreen mode Exit fullscreen mode

Additionally, a trial of using WeakMap to record deps and flags

Although it's not implemented in Vue 3.0 finally. But there was another try to record deps and flags using new data structures in ES2015.

With Set and Map, we could maintain the relationship out of the data itself. So we don't need flag properties like __v_skip inside data any more - actually there are some other flag properties like __v_isReactive and __v_isReadonly in Vue 3.0. For example:

// a Map to record dependets
const dependentMap = new Map()

// track and trigger a property
const track = (type, data, propName) => {
  if (isDryRun && currentFn) {
    if (!dependentMap.has(data)) {
      dependentMap.set(data, new Map())
    }
    if (!dependentMap.get(data).has(propName)) {
      dependentMap.get(data).set(propName, new Set())
    }
    dependentMap.get(data).get(propName).add(currentFn)
  }
}
const trigger = (type, data, propName) => {
  dependentMap.get(data).get(propName).forEach(fn => fn())
}

// observe
let isDryRun = false
let currentFn = null
const observe = fn => {
  isDryRun = true
  currentFn = fn
  fn()
  currentFn = null
  isDryRun = false
}
Enter fullscreen mode Exit fullscreen mode

Then with Proxy/Reflect together, we could track data mutation and trigger dependent functions:

// … handlers
// … observe
// make data and arr reactive
const data = { x: 1, y: 2 }
const proxy = new Proxy(data, handlers)
const arr = [1, 2, 3]
const arrProxy = new Proxy(arr, handlers)

// observe functions
const depA = () => console.log(`x = ${proxy.x}`)
const depB = () => console.log(`y = ${proxy.y}`)
const depC = () => console.log(`x + y = ${proxy.x + proxy.y}`)
const depD = () => {
 let sum = 0
 for (let i = 0; i < arrProxy.length; i++) {
 sum += arrProxy[i]
 }
 console.log(`sum = ${sum}`)
}

// dry-run all dependents
observe(depA)
observe(depB)
observe(depC)
observe(depD)
// output: x = 1, y = 2, x + y = 3, sum = 6

// mutate data
proxy.x = 3
// output: x = 3, x + y = 5
arrProxy[1] = 4
// output: sum = 8
Enter fullscreen mode Exit fullscreen mode

Actually in early beta version of Vue 3.0, it uses WeakMap instead of Map so there won't be any memory leak to be worried about. But unfortunately, the performance is not good when data goes large. So later it changed back to flag properties.

Btw, there is also a trial of using Symbols as the flag property names. With Symbols the extreme cases could also be relieved a lot. But the same, the performance is still not good as normal string property names.

Although these experiments are not preserved finally, I think it's a good choice if you would like to make a pure (but maybe not quite performant) data observer on your own. So just mention this a little bit here.

Quick summary

Anyway we make data reactive first, and observe functions to track all the data they depend on. Then when we mutate the reactive data, relevant functions would be triggered to run again.

All the features and their further issues above have already been completed in Vue 3.0, with the power of ES2015 features.

If you would like to see all the live version of the code sample about explaining main mechanism of reactivity system in Vue from 0.x to 3.0. You could check out this CodePen and see its "Console" panel:

https://codepen.io/Jinjiang/pen/abvMyQa

Now we have already known the basic usage of it - that's passing something into the data option into a Vue component, and then using it into other options like computed, watch, or the template. But this time, in Vue 3.0, it provides more util APIs, like markRaw we mentioned before. So let's take a look at these util APIs.

Encapsulation

1. Proxy for objects

1.1 Basic: reactive(data), readonly(data), markRaw(data)

First let me introduce reactive(data). Just as the name, this API would create a reactive proxy for the data. But here maybe you don't need to use this directly, because the data object you return from the data option will be set up with this API automatically.

Then if you just would like:

  • Some pieces of data immutable, then you could use readonly(data).
  • Some pieces of data not reactive, then you could use markRaw(data).

For example:

import { reactive, readonly, markRaw } from 'vue'

const ComponentFoo = {
  data() {
    return {
      reactiveX: { x: 1 },
      reactiveXInAnotherWay: reactive({ x: 1 }),
      immutableY: readonly({ y: 2 }),
      needntChangeReactivelyZ: markRaw({ z: 3 })
    }
  },
  // ...
}
Enter fullscreen mode Exit fullscreen mode

In this case:

  • If the properties in reactiveX or reactiveXInAnotherWay changed, the view using them in the template will be re-rendered automatically.
  • If you modify the properties in immutableY, there would be an error thrown. At the same time the view won't be re-rendered.
  • If you modify the properties in needntChangeReactivelyZ, the view won't be re-rendered.

Also for marking as raw data, you could mark the data, and then use it anywhere else:

const { markRaw } from 'vue'

const obj = { x: 1 }
const result = markRaw(obj)

console.log(obj === result) // true

const ComponentFoo = {
  data() {
    return {
      obj,
      result
    }
  },
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Here the properties in this.obj and this.result are both non-reactive.

1.2 Utils: isReactive(data), isReadonly(data), isProxy(data), toRaw(data)

Then you may need some util APIs to help you do the job better.

  • For the reactive data proxy, then both isProxy(data) and isReactive(data) would be true.
  • For the readonly data proxy, then both isProxy(data) and isReadonly(data) would be true.
  • For the original data, whether or not it is marked as raw, then all the isProxy(data) and isReactive(data) and isReadonly(data) would be false.
  • For the reactive or readonly data proxy, you could use toRaw(data) to get the raw data back.

1.3 Advanced: shallowReactive(data), shallowReadonly(data)

With these 2 APIs, you could create a "shallow" data proxy, which means they won't setting traps deeply. Only the first-layer properties in these data proxies would be reactive or readonly. For example:

import { shallowReactive, shallowReadonly } from 'vue'

const ComponentFoo = {
  data() {
    return {
      x: shallowReactive({ a: { b: 1 } }),
      y: shallowReadonly({ a: { b: 1 } })
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

In this case, this.x.a is reactive, but this.x.a.b is not; this.y.a is readonly, but this.y.a.b is not.

If you only consume reactive data inside its own component, I think these APIs above are totally enough. But when things come to the real world, sometimes we would like to share states between components, or just abstract state out of a component for better maintenance. So we need more APIs below.

2. Ref for primitive values

A ref could help you to hold a reference for a reactive value. Mostly it's used for a primitive value. For example, somehow we have a number variable named counter in an ES module, but the code below doesn't work:

// store.js

// This won't work.
export const counter = 0;

// This won't works neither.
// import { reactive } from 'vue'
// export const counter = reactive(0)
Enter fullscreen mode Exit fullscreen mode
<!-- foo.vue -->

<template>
  <div>
    {{ counter }}
  </div>
</template>

<script>
import { counter } from './store.js'

export {
  data() {
    return { counter }
  }
}
</script>
Enter fullscreen mode Exit fullscreen mode
<!-- bar.vue -->

<template>
  <button @click="counter++">increment</button>
</template>

<script>
import { counter } from './store.js'

export {
  data() {
    return { counter }
  }
}
</script>
Enter fullscreen mode Exit fullscreen mode

… because primitive values are immutable. When importing and exporting primitive values, we lose the track. To do this, we could use a ref instead.

2.1 Basic: ref(data)

To support the previous example, let's introduce ref(data):

// store.js
import { ref } from 'vue'
export const counter = ref(0)
Enter fullscreen mode Exit fullscreen mode

Then it would work properly.

There is one thing to notice: if you would like to access the value of refs out of a template, you should access its value property instead. For example, if we'd like to modify bar.vue to avoid data option, we could add an increment method to do this, with counter.value:

<!-- bar.vue  -->

<template>
  <button @click="increment">increment</button>
</template>

<script>
import { counter } from './store.js'

export {
  methods: {
    increment() { counter.value++ }
  }
}
</script>
Enter fullscreen mode Exit fullscreen mode

For more caveats, we could do some quick tests later.

2.2 Utils: isRef(data), unref(data)

I think these 2 util APIs are easy to understand:

  • isRef(data): check a value is a ref or not.
  • unref(data): return the value of a ref.

2.3 Proxy to ref: toRef(data, key), toRefs(data)

These 2 util APIs are used for get refs from proxy data:

import { reactive, toRef, toRefs } from 'vue'

const proxy = reactive({ x: 1, y: 2 })

const refX = toRef(proxy, 'x')
proxy.x = 3
console.log(refX.value) // 3

const refs = toRefs(proxy)
proxy.y = 4
console.log(refs.x.value) // 3
console.log(refs.y.value) // 4
Enter fullscreen mode Exit fullscreen mode

As the example above, the typical usage of these APIs is spreading a reactive object into several sub variables and keep the reactivity at the same time.

2.4 Advanced: shallowRef(data)

Only trigger update when the ref.value is assigned by another value. For example:

import { shallowRef } from 'vue'
const data = { x: 1, y: 2 }
const ref = shallowRef(data)

// won't trigger update
ref.value.x = 3

// will trigger update
ref.value = { x: 3, y: 2 }
Enter fullscreen mode Exit fullscreen mode

Case: computed(…)

Similar idea to computed option inside a Vue component. But if you would like to share a computed state out of a component, I suggest you try this API:

// store.js
import { ref, computed } from 'vue'

export const firstName = ref('Jinjiang')
export const lastName = ref('Zhao')

// getter only version
export const fullName = computed(() => `${firstName.value} ${lastName.value}`)

// getter + setter version
export const fullName2 = computed({
  get: () => `${firstName.value} ${lastName.value}`,
  set: (v) => {
    const names = v.split(' ')
    if (names.length > 0) {
      firstName.value = names[0]
    }
    if (names.length > 1) {
      lastName.value = names[names.length - 1]
    }
  }
})
Enter fullscreen mode Exit fullscreen mode
// another-file.js
import { firstName, lastName, fullName, fullName2 } from './store.js'

console.log(fullName.value) // Jinjiang Zhao

firstName.value = 'Evan'
lastName.value = 'You'
console.log(fullName.value) // Evan You

fullName2.value = 'Jinjiang Zhao'
console.log(firstName.value) // Jinjiang
console.log(lastName.value) // Zhao
Enter fullscreen mode Exit fullscreen mode

Case: customRef(…)

This API is my best favorite API in Vue 3.0. Because with this API, you could define how and when to track/trigger your data, during getting or setting the value, that's totally mind-blowing!

For example:

<template>
  <input v-model="email" />
</template>

<script>
import { customRef } from 'vue'
import { validate } from 'isemail'

export default {
  data() {
    return {
      email: customRef((track, trigger) => {
        const value = ''
        return {
          get() {
            track()
            return value
          },
          set(v) {
            if (validate(v)) {
              value = v
              trigger()
            }
          }
        }
      })
    }
  }
}
</script>
Enter fullscreen mode Exit fullscreen mode

That makes real-world user input much easier to handle.

3. Watch for effects

watchEffect(function), watch(deps, callback)

In a Vue component, we could watch data mutations by watch option or vm.$watch() instance API. But the same question: what about watching data mutations out of a Vue component?

Similar to computed reactivity API vs. computed option, we have 2 reactivity APIs: watchEffect and watch.

// store.js
import { ref, watch, watchEffect } from 'vue'

export const counter = ref(0)

// Will print the counter every time it's mutated.
watchEffect(() => console.log(`The counter is ${counter.value}`))

// Do the similar thing with more options
watch(counter, (newValue, oldValue) =>
  console.log(`The counter: from ${oldValue} to ${newValue}`)
)
Enter fullscreen mode Exit fullscreen mode

4. Standalone package & usage

Also in Vue 3.0, we have a standalone package for these. That is @vue/reactivity. You could also import most of the APIs we mentioned above, from this package. So the code is almost the same to above:

import { reactive, computed, effect } from '@vue/reactivity'

const data = { x: 1, y: 2 }
const proxy = reactive(data)
const z = computed(() => proxy.x + proxy.y)

// print 'sum: 3'
effect(() => console.log(`sum: ${z.value}`))

console.log(proxy.x, proxy.y, z.value) // 1, 2, 3

proxy.x = 11 // print 'sum: 13'

console.log(proxy.x, proxy.y, z.value) // 11, 2, 13
Enter fullscreen mode Exit fullscreen mode

The only difference is there is no watch and watchEffect. Instead there is another low-level API named effect. Its basic usage is just similar to watchEffect but more flexible and powerful.

For more details, I suggest you to read the source code directly:

https://github.com/vuejs/vue-next/tree/master/packages/reactivity

So you could even use these APIs in non-Vue related projects as you like.

From now on, you could think about it: with reactivity APIs, what else amazing stuff could you make? 😉

Benefit & caveats

So far we know how reactivity APIs work in Vue 3.0. Comparing to 2.x and earlier version, it:

  • Fully covers all kinds of mutations of data, like adding a new property to an object, setting a value to an index of an array, etc.
  • Fully support all new data structures, like Map and Set.
  • Has better performance.
  • It could be used as a standalone package.

So if you really need or love any of the above, maybe it's time to try.

At the same time, there are some caveats for you:

  • It only works on ES2015+
  • DO use refs for primitive values for keeping the reactivity.
  • The reactive proxy doesn't equal to the original data in JavaScript.

For more details, I prepared a cheat sheet on Gist below:

https://gist.github.com/Jinjiang/f795b943d4315a42077b7261caf25187

Also there are 2 more casual Codesandbox projects I test for myself previously. Maybe it's somehow a little bit useful:

  1. for reactive, readonly, and markRaw: https://codesandbox.io/s/vue-reactivity-tests-1-jm3d4
  2. for ref and computed: https://codesandbox.io/s/vue-reactivity-tests-2-vyykh

Further use cases

So far we know a lot of things about the reactivity system in Vue, from the early version to 3.0. Now it's time to show some use cases based on that.

Composition API

The first thing is definitely the Vue Composition API, which is new in 3.0. With reactivity APIs, we could organize our code logic more flexibly.

import { ref, reactive, readonly, markRaw, computed, toRefs } from 'vue'

export default {
  setup(props) {
    const counter = ref(0)
    const increment = () => counter.value++
    const proxy = reactive({ x: 1, y: 2 })
    const frozen = readonly({ x: 1, y: 2 })
    const oneTimeLargeData = markRaw({ ... })
    const isZero = computed(() => counter.value === 0)
    const propRefs = toRefs(props)

    // could use a,b,c,d,e,f in template and `this`
    return {
      a: counter,
      b: increment,
      c: proxy,
      d: frozen,
      e: oneTimeLargeData,
      f: isZero,
      ...propRefs
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

I don't wanna show more demos about that because they are already everywhere. But IMO, for a further benefit few people talking about is, previously in Vue 2.x and earlier, we are used to putting everything on this, when we:

  • Create reactive data for a component instance.
  • Access data/functions in the template.
  • Access data/functions outside the component instance, mostly it happens when we set a template ref on a sub Vue component.

All 3 things always happen together. That means maybe we just:

  • Would like to access something in the template, but don't need reactivity.
  • Would like to create reactive data, but don't use that in the template.

Vue Composition API elegantly decouples them out by 2 steps:

  1. create reactive data;
  2. decide what the template needs.

Btw, for public instance members, I think the potential problem is still there. However, it's not a big matter so far.

Also, there are some other benefits, including but not limited to:

  • Maintain reusable code without worrying about the naming conflict.
  • Gathering logically related code together, rather than gathering instance members together with the same option type.
  • Better and easier TypeScript support.

Also in Composition API, there are more APIs like provide()/inject(), lifecycle hooks, template refs, etc. For more about Composition API, please check out this URL: https://composition-api.vuejs.org/.

Cross-component state sharing

When sharing data between components. Reactivity APIs is also a good choice. We could even use them out of any Vue component, and finally use them into a Vue app, for example, with the composition APIs provide and inject:

// store.js
import { ref } from 'vue'

// use Symbol to avoid naming conflict
export const key = Symbol()

// create the store
export const createStore = () => {
  const counter = ref(0)
  const increment = () => counter.value++
  return { counter, increment }
}
Enter fullscreen mode Exit fullscreen mode
// App.vue
import { provide } from 'vue'
import { key, createStore } from './store'

export default {
  setup() {
    // provide data first
    provide(key, createStore())
  }
}
Enter fullscreen mode Exit fullscreen mode
// Foo.vue
import { inject } from 'vue'
import { key } from './store'

export default {
  setup() {
    // you could inject state with the key
    // and rename it before you pass it into the template
    const { counter } = inject(key)
    return { x: counter }
  }
}
Enter fullscreen mode Exit fullscreen mode
// Bar.vue
import { inject } from 'vue'
import { key } from './store'

export default {
  setup() {
    // you could inject state with the key
    // and rename it before you pass it into the template
    const { increment } = inject(key)
    return { y: increment }
  }
}
Enter fullscreen mode Exit fullscreen mode

https://codesandbox.io/s/vue-reactivity-shared-state-nkfc0

So once user call y() in Bar.vue, the x in Foo.vue would be updated as well. You don't even need any more state management library to do that. That's quite easy to use.

Remember vue-hooks?

It's not an active project anymore. But I remember after React Hooks first time announced, Evan, the creator of Vue, just gave a POC under Vue in 1 day with less than 100 lines of code.

Here is the live demo in Codesandbox:

https://codesandbox.io/s/jpqo566289

Why it could be done so easily with Vue. I think mostly because of the reactivity system in Vue. It already helps you done most of the job. What we need to do is just encapsulate them into a new pattern or more friendly APIs.

Writing React with Vue reactivity system

So let's try one more step POC. How about using Reactivity APIs in React to create React components?

import * as React from "react";
import { effect, reactive } from "@vue/reactivity";

const Vue = ({ setup, render }) => {
  const Comp = props => {
    const [renderResult, setRenderResult] = React.useState(null);
    const [reactiveProps] = React.useState(reactive({}));
    Object.assign(reactiveProps, props);
    React.useEffect(() => {
      const data = { ...setup(reactiveProps) };
      effect(() => setRenderResult(render(data)));
    }, []);
    return renderResult;
  };
  return Comp;
};

const Foo = Vue({
  setup: () => {
    const counter = ref(0);
    const increment = () => {
      counter.value++;
    };
    return { x: counter, y: increment };
  },
  render: ({ x, y }) => <h1 onClick={y}>Hello World {x.value}</h1>
});
Enter fullscreen mode Exit fullscreen mode

https://codesandbox.io/s/react-vue-reactivity-evdll

I did a little test like above, it's not a full implementation. But somehow we could maintain a basic React component with 2 parts:

  1. Pure data logic with reactivity.
  2. Any data update would be observed and trigger component re-render.

Those correspond to setup and render functions as a Vue component does.

And there is no way to worry about whether or not I write a React hook outside a React component or inside a conditional block. Just code it as you like and make it happen as you imagine.

Final final conclusions

So that's all about the reactivity system in Vue, from early version to the latest 3.0 Beta. I'm still learning a lot of new stuff like programming languages, paradigms, frameworks, and ideas. They are all great and shining. But the reactivity system is always a powerful and elegant tool to help me solve all kinds of problems. And it's still keeping evolved.

With ES2015+, the new Reactivity APIs and its independent package, Composition APIs, Vue 3.0, and more amazing stuff in the ecosystem and community. Hope you could use them or get inspired from them, to build more great things much easier.

Hope you could know Vue and its reactivity system better through this article.

All the code samples in this article: https://gist.github.com/Jinjiang/f9b6f968af980cfd21cfc713e59db91b

Top comments (10)

Collapse
 
techmely profile image
Techmley

I must login in Devto to like and comment this post! It's .... I don't know :v I will share with my colleagues about this post. You deserves more than this, bro.

Collapse
 
knth profile image
KNTH

Really impressive post! Thanks!

Collapse
 
geewhizbang profile image
Geoffrey Swenson • Edited

I am totally confused by this metaphor. I have an ajax function, it alters the data, and nothing I try updates the page.

setup() {

    const cursorPosition = ref('0px');
    const cursorWidth = ref('0px');
    const cursorVisible = ref('visible');
    let menu = ref(filterMenu());

   axios.get('navigation_props')
     .then(response => {
       navigationData = response.data;
       menu = ref(filterMenu());
     })
    .catch(error => {
       console.log(error)
        // Manage errors if found any 
     });

   return {
      menu,
      cursorPosition,
      cursorWidth,
      cursorVisible
   }
},
Enter fullscreen mode Exit fullscreen mode

In the axios call above, menu variable is really getting changed to the new item. But the rendering stays stuck on the default menu that was displayed until the query confirmed I could show other things.

The menu of course is a nested object. The other simpler properties do what is expected when they change, but this doesn't.

Collapse
 
jinjiang profile image
Jinjiang

I guess what you want to do in line 11 should be something like menu.value = filterMenu()

Collapse
 
willwei profile image
willwei

is 'Dep' short for 'dependence'? why call it 'Dep'? it just looks like a subscribers collection.

Collapse
 
jinjiang profile image
Jinjiang

Yes short for dependence. And it just comes from the source code. github.com/vuejs/vue/blob/dev/src/...

Collapse
 
natuan62 profile image
Black Jack

Thanks so much bz this article!

Collapse
 
robertotonino profile image
Roberto Tonino

This is a MASSIVE post! Wow! Still haven't read all of it, but will definitely use it as a first step when learning Vue 3. Thanks!

Collapse
 
b0yblake profile image
Lichhuun

Freaking aws, thank you

Collapse
 
tasenpro profile image
Tasen-pro • Edited

Wow! it's better than the documentation.