Reactivity Fundamentals
There are multiple ways to declare reactive state in the Composition API.
ref()
In the Composition API, the recommended way to declare reactive state is by 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. However, you don't need to append .value when using the ref in the template. For convenience, refs are automatically unwrapped when used inside templates:
<!-- Notice count.value isn't used here -->
<div>{{ count }}</div>Caveat when Unwrapping in Templates
Ref unwrapping in templates only applies if the ref is a top-level property in the template render context.
In the example below, count and object are top-level properties, but object.id is not:
const count = ref(0)
const object = { id: ref(1) }Therefore, this expression does NOT work as expected:
{{ object.id + 1 }}However, note that a ref DOES get unwrapped if it is the final evaluated value of a text interpolation:
<!-- Here ref is the final value of an expression, and it is not used in an operation -->
{{ object.id }}This will render 1.
Why Refs?
You might wonder why Vue uses refs with the .value instead of plain variables. The reason lies in how Vue's reactivity system works.
When a component renders, Vue keeps track of every ref used in the template. If a ref’s value changes later, Vue notices and automatically updates the DOM. This works because Vue tracks when a value is read and when it is changed.
In plain JavaScript, Vue can’t detect when normal variables are accessed or updated. But with objects, Vue can intercept reads and writes using getters and setters.
A ref is basically an object where:
- Reading
.valuelets Vue track dependencies. - Writing to
.valuetells Vue to update anything that depends on it.
Conceptually, a ref object 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 benefit of refs is that you can pass them into functions and still keep:
- Access to the latest value.
- The reactivity connection.
This makes refs especially useful when organizing or reusing complex logic.
Deep Reactivity
Refs can hold any value type, including deeply nested objects, arrays, or JavaScript built-in data structures like Map.
A ref will make its value deeply reactive. This means you can expect changes to be 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')
}It is also possible to opt-out of deep reactivity using shallow refs. With shallow refs, only access to .value is tracked for reactivity. Mutations to nested properties will not trigger updates.
Shallow refs can be used for optimizing performance by avoiding the observation cost of large objects, or in cases where the inner state is managed by an external library.
import { shallowRef } from 'vue'
const state = shallowRef({ count: 1 })
// ❌ Does NOT trigger a change
state.value.count = 2
// ✅ Does trigger a change
state.value = { count: 2 }DOM Update Timing
When you mutate reactive state, the DOM is updated automatically, but not immediately.
Vue does not apply DOM updates synchronously. Instead, it buffers them until the "next tick" in the update cycle to ensure that each component updates only once no matter how many state changes you have made.
If you need to wait until the DOM has finished updating after a state change, you can use the nextTick() global API:
import { nextTick } from 'vue'
async function increment() {
count.value++
// ❌ DOM still shows the old value
console.log(document.querySelector('#counter').textContent)
await nextTick()
// ✅ DOM is now updated
console.log(document.querySelector('#counter').textContent)
}reactive()
You can also declare reactive state using the reactive() API. Unlike a ref which wraps the inner value in a special object, reactive() makes an object itself reactive:
import { reactive } from 'vue'
const state = reactive({ count: 0 })NOTE
A Proxy is a JavaScript feature that allows code to intercept and customize operations on an object, such as reading or writing a property.
Reactive objects in Vue are implemented using JavaScript Proxies and behave just like normal objects. The difference is that Vue is able to intercept the access and mutation of all properties of a reactive object for reactivity tracking and triggering.
reactive() is deep by default: nested objects are also made reactive when accessed. Internally, ref() uses reactive() when its value is an object.
Similar to shallow refs, there is also the shallowReactive() API for opting-out of deep reactivity.
Reactive Proxy vs. Original
The value returned by reactive() is a Proxy, not the original object:
const raw = {}
const proxy = reactive(raw)
// proxy is NOT equal to the original.
console.log(proxy === raw) // falseOnly the proxy is reactive. Mutating the original object will not trigger updates, so you should always work with the proxied version of your state.
Vue guarantees proxy consistency. So calling reactive() on the same object always returns the same proxy, and calling reactive() on an existing proxy also returns that same proxy:
// calling reactive() on the same object returns the same proxy
console.log(reactive(raw) === proxy) // true
// calling reactive() on a proxy returns itself
console.log(reactive(proxy) === proxy) // trueThis applies to nested objects as well. Due to deep reactivity, nested objects are also proxies:
const proxy = reactive({})
const raw = {}
proxy.nested = raw
console.log(proxy.nested === raw) // falseLimitations of reactive()
The reactive() API has a few limitations:
- Limited value types:
reactive()can only be used with object types (objects, arrays, and collection types such asMapandSet). It cannot hold primitive types such asstring,numberorboolean. - Cannot replace entire object: Vue tracks reactivity by observing property access on the same object reference. If you replace the reactive object with a new one, the original reactivity connection is lost.
let state = reactive({ count: 0 })
// ❌ Replacing the object breaks reactivity
state = reactive({ count: 1 })- Not destructure-friendly: When you destructure a primitive type property from a reactive object into a local variable or when you pass that property into a function, the reactivity connection is lost.
const state = reactive({ count: 0 })
// count is disconnected from state.count when destructured.
let { count } = state
// does not affect original state
count++
// the function receives a plain number and
// won't be able to track changes to state.count
// you would need to pass the entire object or a ref to retain reactivity
callSomeFunction(state.count)TIP
Due to these limitations, it is generally recommended to use ref() as the primary API for declaring reactive state.
Refs as Reactive Object Properties
A ref is automatically unwrapped when accessed or mutated as a property of a reactive object. In other words, it behaves like a normal property:
const count = ref(0)
const state = reactive({ count })
console.log(state.count) // 0
state.count = 1
console.log(count.value) // 1If you assign a new ref to the same property, it replaces the old ref:
const otherCount = ref(2)
state.count = otherCount
console.log(state.count) // 2
console.log(count.value) // 1 (original ref is now disconnected)Ref unwrapping only works inside deep reactive objects. Shallow reactive objects do not unwrap nested refs automatically.
Caveat with Arrays and Collections
Ref unwrapping does not apply to elements of reactive arrays or native collections (Map, Set). In these cases, you need to use .value explicitly:
const books = reactive([ref('Vue 3 Guide')])
console.log(books[0].value) // need .value
const map = reactive(new Map([['count', ref(0)]]))
console.log(map.get('count').value) // need .value