On this page

ref() and reactive(): understanding Vue's reactivity system

14 min read TextCh. 2 — Reactivity

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 = 1

2. 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.count

toRefs() 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" failed

This 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 otherwise

unref() 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

  1. Explore auto-unwrapping: Create a component with a ref<number> and log count (the ref) vs count.value. Observe that the template renders correctly without .value.
  2. Destructuring comparison: Create a reactive() object, destructure it with and without toRefs(), and verify which one updates the UI when you mutate the original.
  3. shallowRef optimization: Create a shallowRef wrapping a large array. Mutate a nested property and call triggerRef() to force an update. Compare this to the behavior with a regular ref.

In the next lesson, we will explore computed(), watch(), and watchEffect() — the tools for deriving values and reacting to data changes.

When to use ref vs reactive
Use ref() for primitive values (strings, numbers, booleans) and for refs you need to reassign entirely. Use reactive() for objects where you always access properties and never need to replace the whole object.
Destructuring breaks reactive()
Destructuring a reactive object loses reactivity: const { name } = user makes name a plain string copy. Always use toRefs() or keep the full object reference when destructuring.
Vue 3.5 reactive props destructure
In Vue 3.5+, props defined with defineProps() can be destructured directly in <script setup> while keeping reactivity. This is a new compiler feature — it does NOT apply to reactive() objects.
vue
<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>