Skip to content

Reactivity Fundamentals

Vue.js offers a powerful reactivity system that automatically updates the DOM when underlying data changes. This document covers key concepts of Vue’s reactivity system.

In Composition API, the recommended way to declare reactive state is using the ref() function:

import { ref } from 'vue'
const count = ref(0)

ref() takes the argument and returns it wrapped within a ref object with a .value property:

const count = ref(0)
console.log(count) // { value: 0 }
console.log(count.value) // 0
// Modifying the value
count.value++
console.log(count.value) // 1

To access refs in a component’s template, declare and return them from a component’s setup() function:

import { ref } from 'vue'
export default {
// `setup` is a special hook dedicated for the Composition API.
setup() {
const count = ref(0)
// expose the ref to the template
return {
count
}
}
}

In the template:

<div>{{ count }}</div>

Important: Notice that we do not need to append .value when using the ref in the template. For convenience, refs are automatically unwrapped when used inside templates.

With Single-File Components (SFCs), you can use the simpler <script setup> syntax:

<script setup>
import { ref } from 'vue'
const count = ref(0)
function increment() {
count.value++
}
</script>
<template>
<button @click="increment">
{{ count }}
</button>
</template>

Top-level imports, variables, and functions declared in <script setup> are automatically usable in the template of the same component.

You might wonder why we need refs with the .value property instead of plain variables. This is related to how Vue’s reactivity system works:

  1. When you use a ref in a template and change its value later, Vue automatically detects the change and updates the DOM.
  2. This is made possible with a dependency-tracking based reactivity system.
  3. When a component renders for the first time, Vue tracks every ref that was used during the render.
  4. Later, when a ref is mutated, it will trigger a re-render for components that are tracking it.

In standard JavaScript, there’s no way to detect access or mutation of plain variables. However, we can intercept the get and set operations of an object’s properties using getter and setter methods.

Conceptually, a ref looks like this:

// pseudo code, not actual implementation
const myRef = {
_value: 0,
get value() {
track()
return this._value
},
set value(newValue) {
this._value = newValue
trigger()
}
}

Another way to declare reactive state is with the reactive() API:

import { reactive } from 'vue'
const state = reactive({ count: 0 })

Usage in template:

<button @click="state.count++">
{{ state.count }}
</button>
  1. reactive() makes an object itself reactive, while ref() wraps the inner value in a special object.
  2. reactive() creates a proxy of the original object.
  3. reactive() converts the object deeply - nested objects are also wrapped with reactive().
  1. Limited value types: It only works for object types (objects, arrays, and collection types like Map and Set). It cannot hold primitive types like string, number, or boolean.

  2. Cannot replace entire object: Since Vue’s reactivity tracking works over property access, we must always keep the same reference to the reactive object.

    let state = reactive({ count: 0 })
    // the above reference ({ count: 0 }) is no longer tracked
    state = reactive({ count: 1 })
  3. Not destructure-friendly: When we destructure a reactive object’s primitive type property into local variables, or pass that property into a function, we lose the reactivity connection.

    const state = reactive({ count: 0 })
    // count is disconnected from state.count when destructured
    let { count } = state
    // does not affect original state
    count++

Due to these limitations, Vue recommends using ref() as the primary API for declaring reactive state.

Refs can hold any value type, including deeply nested objects, arrays, or JavaScript built-in data structures like Map.

A ref makes its value deeply reactive. This means changes are detected even when you mutate nested objects or arrays:

import { ref } from 'vue'
const obj = ref({
nested: { count: 0 },
arr: ['foo', 'bar']
})
function mutateDeeply() {
// these will work as expected.
obj.value.nested.count++
obj.value.arr.push('baz')
}

Non-primitive values are turned into reactive proxies via reactive(), which is called by ref() internally when the ref value is an object.

A ref is automatically unwrapped when accessed or mutated as a property of a reactive object:

const count = ref(0)
const state = reactive({
count
})
console.log(state.count) // 0
state.count = 1
console.log(count.value) // 1

If a new ref is assigned to a property linked to an existing ref, it will replace the old ref:

const otherCount = ref(2)
state.count = otherCount
console.log(state.count) // 2
// original ref is now disconnected from state.count
console.log(count.value) // 1

Unlike reactive objects, there is no unwrapping performed when the ref is accessed as an element of a reactive array or a native collection type like Map:

const books = reactive([ref('Vue 3 Guide')])
// need .value here
console.log(books[0].value)
const map = reactive(new Map([['count', ref(0)]]))
// need .value here
console.log(map.get('count').value)

Ref unwrapping in templates only applies if the ref is a top-level property in the template render context:

const count = ref(0)
const object = { id: ref(1) }

In the template:

  • {{ count + 1 }} works as expected
  • {{ object.id + 1 }} does NOT work, because object.id is not unwrapped

To fix this, we can destructure the ref into a top-level property:

const { id } = object

Then {{ id + 1 }} will work correctly.

A ref does get unwrapped if it is the final evaluated value of a text interpolation (a {{ }} tag), so {{ object.id }} will render 1 and is equivalent to {{ object.id.value }}.

When you mutate reactive state, the DOM is updated automatically. However, DOM updates are not applied synchronously. Instead, Vue buffers them until the “next tick” in the update cycle to ensure each component updates only once no matter how many state changes you have made.

To wait for the DOM update to complete after a state change, you can use the nextTick() global API:

import { nextTick } from 'vue'
async function increment() {
count.value++
await nextTick()
// Now the DOM is updated
}