Back
Vue 3

Master Vue 3 Reactivity System with Ref and Reactive

Deeply understand Vue 3's reactivity system. Learn when to use ref vs reactive, computed properties, watchers, and avoid common pitfalls.

Francisco ZapataWritten by Francisco Zapata
January 1, 202612 min read
Master Vue 3 Reactivity System with Ref and Reactive

The reactivity system is the heart of Vue.js. In Vue 3, this system was rewritten from scratch using JavaScript Proxies, making it more efficient and powerful. Understanding how reactivity works and when to use each tool is essential for writing effective Vue applications.

How Does Reactivity Work in Vue 3?

Vue 3 uses JavaScript's Proxy to intercept read and write operations on reactive objects. When you access a reactive property in a template or computed, Vue tracks that dependency. When the property changes, Vue knows exactly which components need to update.

// Internally, Vue does something similar to this:
const handler = {
  get(target, key) {
    track(target, key) // Track the dependency
    return target[key]
  },
  set(target, key, value) {
    target[key] = value
    trigger(target, key) // Trigger updates
    return true
  }
}
const proxy = new Proxy(data, handler)

ref() vs reactive(): When to Use Each?

This is the most common question when starting with Vue 3. Both create reactive data, but they have important differences.

ref()

ref() wraps a value in an object with a .value property. It works with any data type: primitives, objects, arrays, etc.

import { ref } from 'vue'

const count = ref(0)          // primitive
const name = ref('Vue')       // string
const user = ref({ name: 'Ana' }) // object
const items = ref([1, 2, 3])  // array

// Access in JavaScript: use .value
console.log(count.value) // 0
count.value++

// In templates: .value is NOT needed
// <p>{{ count }}</p>

reactive()

reactive() creates a reactive proxy of the object. It only works with reference types (objects, arrays, Maps, Sets).

import { reactive } from 'vue'

const state = reactive({
  count: 0,
  user: { name: 'Ana' },
  items: [1, 2, 3]
})

// Direct access without .value
state.count++
console.log(state.user.name)

Direct Comparison

| Feature | ref() | reactive() |

|---|---|---|

| Supported types | All (primitives + objects) | Objects only |

| JS access | .value | Direct |

| Template access | Automatic (no .value) | Direct |

| Reassignment | ref.value = newVal | Cannot reassign |

| Destructuring | Keeps reactivity | Loses reactivity |

Which One Should You Choose?

The official Vue recommendation is to use ref() by default. The reasons are:

1. Works with any data type

2. Can be fully reassigned

3. Does not lose reactivity when passed to functions

4. The .value makes it explicit that you are working with reactive data

// Recommended: ref for everything
const count = ref(0)
const user = ref({ name: 'Ana', age: 25 })

// reactive is useful for grouped state
const form = reactive({
  email: '',
  password: '',
  rememberMe: false
})

shallowRef and shallowReactive

To optimize performance with large objects, Vue offers shallow versions:

import { shallowRef, shallowReactive } from 'vue'

// Only .value assignment is reactive
const largeList = shallowRef([])
largeList.value = [...newItems] // Reactive
largeList.value.push(item)     // NOT reactive

// Only top-level properties are reactive
const state = shallowReactive({
  nested: { count: 0 }
})
state.nested = { count: 1 }  // Reactive
state.nested.count++          // NOT reactive

toRef and toRefs: Bridging Reactivity

These utilities are essential when working with props or when you need to create refs from reactive objects.

toRef()

Creates a ref that stays in sync with a reactive object property:

import { reactive, toRef } from 'vue'

const state = reactive({ count: 0 })
const countRef = toRef(state, 'count')

countRef.value++ // state.count also changes
state.count++    // countRef.value also changes

toRefs()

Converts all properties of a reactive object into individual refs:

import { reactive, toRefs } from 'vue'

const state = reactive({
  count: 0,
  name: 'Vue'
})

// Destructure WITHOUT losing reactivity
const { count, name } = toRefs(state)

count.value++ // state.count changes too

This is especially useful in composables:

export function useFeature() {
  const state = reactive({
    loading: false,
    data: null,
    error: null
  })

  return toRefs(state)
}

// Usage: destructure with reactivity intact
const { loading, data, error } = useFeature()

Computed Properties

Computed properties are derived values that are automatically cached:

import { ref, computed } from 'vue'

const firstName = ref('Francisco')
const lastName = ref('Zapata')

// Read-only computed
const fullName = computed(() => `${firstName.value} ${lastName.value}`)

// Writable computed
const fullNameWritable = computed({
  get() {
    return `${firstName.value} ${lastName.value}`
  },
  set(newValue) {
    const [first, ...rest] = newValue.split(' ')
    firstName.value = first
    lastName.value = rest.join(' ')
  }
})

fullNameWritable.value = 'Juan Pérez' // Updates firstName and lastName
Important rule: Computed properties should not have side effects. Do not make HTTP requests or modify the DOM inside a computed.

Watchers: watch vs watchEffect

watch()

Observes specific sources and gives you access to the previous value:

import { ref, watch } from 'vue'

const count = ref(0)

// Watch a ref
watch(count, (newVal, oldVal) => {
  console.log(`Changed from ${oldVal} to ${newVal}`)
})

// Watch multiple sources
const name = ref('Ana')
watch([count, name], ([newCount, newName], [oldCount, oldName]) => {
  console.log('Something changed')
})

// Watch specific property of reactive
const state = reactive({ nested: { count: 0 } })
watch(
  () => state.nested.count,
  (newVal) => console.log('nested.count changed:', newVal)
)

// Options: immediate and deep
watch(user, (newUser) => {
  saveToAPI(newUser)
}, { immediate: true, deep: true })

watchEffect()

Runs immediately and automatically tracks dependencies:

import { ref, watchEffect } from 'vue'

const url = ref('/api/users')
const data = ref(null)

// Runs on init and whenever url changes
watchEffect(async () => {
  const response = await fetch(url.value)
  data.value = await response.json()
})

When to Use Each?

  • watch: When you need the previous value, when you want precise control over what to observe, or when the logic should not run immediately.
  • watchEffect: When immediate execution is desirable and the dependencies are obvious from the code.

Common Mistakes and How to Avoid Them

1. Losing Reactivity When Destructuring reactive

const state = reactive({ count: 0 })

// BAD: loses reactivity
const { count } = state

// GOOD: use toRefs
const { count } = toRefs(state)

2. Reassigning a reactive Object

let state = reactive({ count: 0 })

// BAD: loses the proxy reference
state = reactive({ count: 1 })

// GOOD: modify properties
state.count = 1
// Or use ref if you need reassignment
const state = ref({ count: 0 })
state.value = { count: 1 }

3. Forgetting .value in JavaScript

const count = ref(0)

// BAD
if (count === 0) { } // Always false

// GOOD
if (count.value === 0) { }

4. Mutating Props Directly

// BAD: Vue will throw a warning
props.modelValue = 'new value'

// GOOD: emit event
emit('update:modelValue', 'new value')

Conclusion

Vue 3's reactivity system is powerful and flexible. The general rule is: use ref() as your default tool, turn to reactive() for grouped state, and leverage toRefs() to maintain reactivity when destructuring. Mastering these primitives will allow you to write cleaner, more predictable components.

Comments (0)

Leave a comment

Be the first to comment