On this page
ref() and reactive(): understanding Vue's reactivity system
Vue's reactivity system
Vue's reactivity system is the engine that makes the UI update automatically when data changes. At its core, it uses JavaScript Proxies to intercept property reads and writes. When a component renders, Vue tracks which reactive sources were accessed (the "dependencies"). When any of those sources change, Vue schedules a re-render.
Understanding this mechanism helps you write efficient components and avoid common pitfalls.
ref()
ref() is the most fundamental reactive primitive in Vue 3. It wraps a single value — primitive or object — in a reactive container object that has a single property: .value.
import { ref } from 'vue'
const count = ref(0) // Ref<number>
const name = ref('Ada') // Ref<string>
const items = ref<string[]>([]) // Ref<string[]>
// Reading
console.log(count.value) // 0
// Writing
count.value = 42
count.value++
// Mutating arrays (Vue tracks mutations)
items.value.push('new item')
items.value = ['replaced', 'array']Why .value?
JavaScript cannot intercept assignments to plain variables. A wrapper object is the only way to make a primitive value reactive — the Proxy can intercept reads/writes to the wrapper's .value property.
Template auto-unwrapping
Inside <template>, Vue automatically unwraps ref values, so you never write .value in templates:
<template>
<!-- Automatically uses count.value -->
<p>{{ count }}</p>
<!-- .value IS required in JS/TS, NOT in templates -->
</template>Type inference
TypeScript infers the type from the initial value. For more complex types, annotate explicitly:
const count = ref(0) // Ref<number>
const user = ref<User | null>(null) // Ref<User | null>
// You can also use the generic form
const items = ref<string[]>([])reactive()
reactive() makes a plain object deeply reactive using a Proxy. Unlike ref(), there is no .value — you access properties directly, exactly as with a plain object.
import { reactive } from 'vue'
const state = reactive({
count: 0,
name: 'Ada',
scores: [95, 87, 92],
address: {
city: 'London',
country: 'UK',
},
})
// Access — no .value
console.log(state.count) // 0
console.log(state.address.city) // 'London'
// Mutation — just like a plain object
state.count++
state.address.city = 'New York'
state.scores.push(100)Reactivity is deep: nested objects are also made reactive automatically. Vue tracks changes at every level.
Limitations of reactive()
reactive() has two important limitations you must understand:
1. Reassigning the variable breaks reactivity
let state = reactive({ count: 0 })
// WRONG: reassigning loses the reactive reference
state = { count: 1 } // This is now a plain object!
// CORRECT: mutate properties instead
state.count = 12. Destructuring breaks reactivity
const state = reactive({ count: 0, name: 'Ada' })
// WRONG: primitives are copied by value
const { count, name } = state
count++ // Does NOT update state.count!
// CORRECT: use toRefs() first
const { count, name } = toRefs(state)
count.value++ // Updates state.count ✓toRefs() and toRef()
toRefs() converts all properties of a reactive object into individual Ref objects. Each resulting ref is linked to its source property — changes through the ref update the reactive object, and changes to the reactive object are reflected through the ref.
import { reactive, toRefs, toRef } from 'vue'
const state = reactive({ count: 0, name: 'Ada' })
// Convert all properties
const { count, name } = toRefs(state)
count.value++ // Updates state.count
// Convert a single property
const count2 = toRef(state, 'count')
count2.value = 99 // Updates state.counttoRefs() is especially useful when returning reactive state from a composable function — it lets callers destructure the result without losing reactivity.
shallowRef() and shallowReactive()
For performance-sensitive use cases, Vue provides shallow variants that only make the top level reactive:
import { shallowRef, shallowReactive } from 'vue'
// Only .value itself is reactive; inner object is NOT tracked
const largeData = shallowRef({ items: [] })
// Only top-level properties are reactive
const config = shallowReactive({ theme: 'dark', settings: { /* nested not tracked */ } })Use these when you have large objects and manually control when updates propagate using triggerRef().
readonly()
readonly() creates a read-only proxy of a reactive object. Mutations throw a warning in development:
import { reactive, readonly } from 'vue'
const original = reactive({ count: 0 })
const readonlyProxy = readonly(original)
readonlyProxy.count++ // Warning: Set operation on key "count" failedThis is useful for exposing state from a composable or a store without allowing consumers to mutate it directly.
Reactivity utilities
Vue exports several helper functions for inspecting reactivity:
import { isRef, isReactive, isReadonly, isProxy, unref } from 'vue'
const r = ref(0)
const obj = reactive({ a: 1 })
isRef(r) // true
isReactive(obj) // true
unref(r) // 0 — returns .value if ref, value itself otherwiseunref() is particularly handy in composables that accept either a plain value or a ref as an argument:
function useDouble(input: number | Ref<number>) {
return computed(() => unref(input) * 2)
}ref() vs reactive() — choosing the right tool
| Criterion | ref() | reactive() |
|---|---|---|
| Primitive values | Yes | No |
| Objects | Yes (with .value) | Yes (direct access) |
| Full reassignment | Yes (ref.value = newObj) | No |
| Destructuring | Use .value | Use toRefs() |
| Template usage | Auto-unwrapped | Direct access |
| Best for | Scalars, single values, nullable | Grouped state objects |
A common pattern is to use ref() for everything and rely on template auto-unwrapping, which avoids the destructuring pitfall entirely. For structured state with many related properties (like a form), reactive() can be more ergonomic.
Practice
- Explore auto-unwrapping: Create a component with a
ref<number>and logcount(the ref) vscount.value. Observe that the template renders correctly without.value. - Destructuring comparison: Create a
reactive()object, destructure it with and withouttoRefs(), and verify which one updates the UI when you mutate the original. - shallowRef optimization: Create a
shallowRefwrapping a large array. Mutate a nested property and calltriggerRef()to force an update. Compare this to the behavior with a regularref.
In the next lesson, we will explore computed(), watch(), and watchEffect() — the tools for deriving values and reacting to data changes.
<script setup lang="ts">
import { ref, reactive, toRefs, isRef, isReactive } from 'vue'
// --- ref() ---
// Wraps any value in a reactive container
// Access/mutate through .value in JS; template unwraps automatically
const count = ref(0)
const title = ref('Vue Reactivity')
const tags = ref<string[]>(['vue', 'typescript'])
function addTag(tag: string) {
// Mutate through .value
tags.value.push(tag)
}
// --- reactive() ---
// Makes a plain object deeply reactive; NO .value needed
const user = reactive({
name: 'Ada Lovelace',
age: 36,
address: {
city: 'London',
country: 'UK',
},
})
function birthday() {
user.age++ // Direct mutation, no .value
}
// --- toRefs() ---
// Converts reactive object properties to individual refs
// Preserves reactivity when destructuring
const { name, age } = toRefs(user)
// name.value and age.value are now linked to user.name / user.age
function rename(newName: string) {
name.value = newName // Also updates user.name
}
// --- Type checks ---
console.log(isRef(count)) // true
console.log(isReactive(user)) // true
console.log(isRef(name)) // true (from toRefs)
</script>
<template>
<section>
<h2>{{ title }}</h2>
<!-- Template auto-unwraps refs; no .value needed here -->
<p>Count: {{ count }}</p>
<button type="button" @click="count++">Increment</button>
<h3>Tags</h3>
<ul>
<li v-for="tag in tags" :key="tag">{{ tag }}</li>
</ul>
<button type="button" @click="addTag('pinia')">Add Pinia tag</button>
<h3>User (reactive)</h3>
<p>{{ user.name }}, age {{ user.age }}</p>
<button type="button" @click="birthday">Happy Birthday!</button>
<h3>Destructured (toRefs)</h3>
<p>Name via ref: {{ name }}</p>
<button type="button" @click="rename('Grace Hopper')">Rename</button>
</section>
</template>
Sign in to track your progress