Understanding Vue 3 Reactivity: ref, reactive, computed, watch
AI generated
<v/>
{ }
Vue 3 · Reactivity · ref · reactive · computed · watch
Understanding Vue 3 Reactivity
ref, reactive, computed and watch from the inside

Vue 3 reactivity is not magic, it is a precise system built from JavaScript Proxy, dependency tracking and scheduling. Anyone who understands how ref(), reactive(), computed() and watch() work internally stops making reactivity mistakes, picks the right tool for every use case, and knows immediately why a component is not re-rendering. This article explains the reactivity system fully and with practical examples.

20 min read ref · reactive · computed · watch · watchEffect · Proxy Vue 3.4+ · Composition API · JavaScript

1. JavaScript Proxy: the technical foundation of Vue reactivity

The Vue 3 reactivity system is built on JavaScript Proxy, a feature introduced in ECMAScript 6 in 2015 that lets you create a stand-in for an object which can intercept and modify operations performed on it. When Vue wraps an object with reactive(), it creates a Proxy that intercepts every read (get) and write (set) of properties. On read, Vue registers who is currently reading, the active effect function. On write, Vue notifies all registered readers that the value has changed. That is the core of the reactivity system.

Vue 2 used Object.defineProperty() instead of Proxy. That led to well-known limitations: new properties added to existing objects could not be made reactive (Vue.set() was required), and array mutations had to go through patched methods like push(), splice() and sort(). With Proxy in Vue 3, these limitations disappear, new properties are automatically reactive, and array indices as well as length are tracked correctly. The Vue 3 reactivity system is therefore more complete and more consistent than its predecessor.

Proxy only works with objects, not with primitive values such as numbers, strings or booleans. That is why Vue introduces ref() as a separate construct: for primitive values, a wrapper object { value: ... } is created, which is then made reactive via Proxy. Accessing it through .value is not a stylistic choice but a technical necessity: JavaScript cannot intercept reads and writes on primitive values through Proxy. ref.value is the Proxy-intercepted property access on the wrapper object.

2. Track and trigger: how dependency tracking works

The Vue reactivity system manages active effects through a global stack. When a reactive function runs, a template render, a computed() getter, or a watchEffect() callback, it is pushed onto this stack and counts as the "active effect". Every get access on a reactive property internally calls track(): Vue stores the connection between the reactive property and the current active effect. This connection structure is a map of targets to maps of properties to sets of effects, the dependency graph of the Vue reactivity system.

When a reactive property is set, the Proxy handler calls trigger(): Vue looks up all effects that have this property as a dependency and re-runs them. Template render effects are not executed synchronously right away, but collected in an asynchronous queue for the next microtask. That is why multiple state changes within a synchronous block of code only trigger a single re-render, Vue batches updates. With nextTick() you can wait for this microtask to complete and then access the updated DOM.

3. ref(): making primitives reactive

ref() is the main tool of the Vue reactivity system for individual values. Calling ref(0) creates an object { value: 0 } whose value property is made reactive via Proxy. In <script setup>, a ref is read and set through .value. In templates, the .value is dropped, Vue automatically unwraps refs in the template context. That is a deliberate design decision: in the JavaScript context, the explicit ref API is more consistent; in the template, .value everywhere would be redundant syntax.

ref() works with any type: primitive values, objects, arrays, even other refs. If you put an object inside a ref, ref({ name: 'Vue', version: 3 }), the object itself is made reactive via reactive(). That means myRef.value.name = 'Nuxt' is reactive without needing to replace myRef.value. This differs from a plain ref around a primitive value: there, only the assignment myRef.value = newValue is tracked reactively. When reading and writing object properties directly on myRef.value, the inner Proxy takes over.


// ref(), the primary reactive primitive in Vue 3
import { ref, isRef, unref } from 'vue'

// Primitive value, .value access is the tracked property
const count = ref(0)
count.value++  // triggers re-render of components that read count.value

// Object ref, inner object is made reactive via reactive()
const user = ref({ name: 'Alice', role: 'admin' })
user.value.name = 'Bob'  // reactive, inner object is a Proxy
user.value = { name: 'Charlie', role: 'user' }  // also reactive, replaces entire ref

// Template ref, DOM element reference, starts as null
const inputEl = ref<HTMLInputElement | null>(null)
// <input ref="inputEl" /> - Vue sets inputEl.value to the DOM element after mount

// isRef and unref utilities
console.log(isRef(count))    // true
console.log(unref(count))    // 0, same as count.value for refs, or the value itself for non-refs

// Ref in reactive(), automatically unwrapped (no .value needed)
import { reactive } from 'vue'
const state = reactive({ count, message: 'hello' })
state.count  // 0, no .value needed inside reactive()
state.count++  // updates count.value, reactive

4. reactive(): making objects reactive

reactive() creates a Proxy for an object directly. No wrapper, no .value, property access is direct: state.count instead of state.count.value. That sounds more convenient than ref(), but it carries a crucial limitation: Vue reactivity is lost when the object is destructured. const { count } = reactive({ count: 0 }), and count is now a plain number, not a Proxy-intercepted property. Changes to count are not reactive. This is the most common mistake when using reactive().

The fix for safely destructuring from reactive() objects is toRefs(): it converts every property of a reactive object into an individual ref. That way, each destructured property retains its reactivity through the .value binding. reactive() is particularly suited for closely related groups of state that are treated as a unit, for example a form object with many fields. In Pinia stores, the setup style uses ref() for individual values instead of reactive() for the whole state, because that improves type safety and DevTools tracking.

5. ref vs. reactive: when to use which

The question "ref or reactive?" occupies every developer starting out with the Vue 3 reactivity system. The Vue core team's answer today is clear: prefer ref(). The reason: ref() is more consistent. It works for every type, its .value interface is unambiguous and makes reactive values immediately recognizable in code. When you see something accessed through .value, you know instantly: that is a reactive value. With reactive() objects, without a type definition it is not visible whether a property is reactive or not.

reactive() makes sense when you want to model a group of tightly related values as a natural unit and will never destructure it. A form object const form = reactive({ email: '', password: '', remember: false }), always addressed as form.email, form.password, is a valid use case for reactive(). For everything else: ref(). In Pinia stores, composables and general Composition API code, ref() has the upper hand because it behaves better in TypeScript and does not suffer destructuring loss.

6. computed(): reactive calculations with caching

computed() in the Vue reactivity system is a ref with automatic caching. On the first access to computed.value, Vue runs the getter callback and stores the result. On every further access, Vue returns the cached result, without running the getter again, as long as none of the reactive values read inside the getter have changed. That is the core promise of computed(): same inputs, cached result; different inputs, recomputation.

The most common misuse of computed() is a getter with side effects. A getter that makes an API call, writes to a store's state, or sets a global value is used incorrectly. computed() getters must be purely functional: same input, same output, no side effects. For side effects triggered by reactivity changes, watch() is the right tool. The second common mistake: computed properties for parameterized queries, const getItem = computed(() => (id: string) => items.value.find(i => i.id === id)). The outer computed() never invalidates because it does not read any reactive dependency in its outer scope. For parameterized queries, a regular function or a Map-based cache in the store is the better solution.


// computed(), cached reactive derivation
import { ref, computed } from 'vue'

const items = ref([
  { id: '1', name: 'Vue 3', price: 0, category: 'framework' },
  { id: '2', name: 'Pinia', price: 0, category: 'state' },
  { id: '3', name: 'Vite', price: 0, category: 'tooling' },
])
const filterCategory = ref('all')
const searchQuery = ref('')

// Cached: only recalculates when items.value or filterCategory.value changes
const filteredItems = computed(() => {
  const category = filterCategory.value
  const query = searchQuery.value.toLowerCase()

  return items.value.filter(item => {
    const matchesCategory = category === 'all' || item.category === category
    const matchesSearch = item.name.toLowerCase().includes(query)
    return matchesCategory && matchesSearch
  })
})

// Writable computed, getter + setter
const fullName = computed({
  get: () => `${firstName.value} ${lastName.value}`,
  set: (value: string) => {
    const [first, ...rest] = value.split(' ')
    firstName.value = first
    lastName.value = rest.join(' ')
  },
})
// fullName.value = 'Alice Wonderland' - updates both firstName and lastName

7. watch() and watchEffect(): controlling side effects reactively

watch() and watchEffect() are the tools for side effects in the Vue reactivity system. The difference is fundamental: watchEffect() runs the callback immediately, automatically tracks every reactive value read inside the callback, and re-runs it whenever one of these values changes. watch() is explicit: you specify the observed sources, and the callback receives the old and new value as parameters. watch() does not run the callback immediately (unless { immediate: true } is set), which makes it suited to reactions on value changes that need to know the previous value.

watchEffect() is simpler, but has a trap: because it automatically tracks every reactive access inside the callback, it can build up more dependencies than intended. A helper function that internally reads another reactive object gets automatically registered as a dependency, even if that dependency is not conceptually intended. In these cases, watch() with explicit sources is the more controlled choice. Both variants return a stop function, and both accept onCleanup callbacks for tearing down side effects, the pattern used for event listeners and timers in composables.

8. Reactivity loss: the most common mistakes

Reactivity loss is the most common bug when getting started with the Vue reactivity system. The most common cause: destructuring. Every const { count } = reactiveObjOrStore access without toRefs() or storeToRefs() creates a non-reactive copy. This applies to reactive() objects, to Pinia stores, and to composable return values that internally use reactive(). A simple rule: whenever you destructure from a reactive object, always use toRefs(). Whenever you destructure from a Pinia store, always use storeToRefs() for state and getters.

The second common source of reactivity loss: referencing reactive values outside the reactive context. When a reactive variable is assigned to a plain variable, const value = myRef.value instead of const value = myRef, value loses the reactivity binding. Changes to myRef.value do not update value. That is not a bug but JavaScript semantics: primitive values are copied by value. Only objects are passed by reference. ref() wrappers solve this because the wrapper object is passed by reference, only .value is the primitive content.


// Common Vue 3 reactivity loss patterns and fixes

import { ref, reactive, toRefs, computed, watchEffect } from 'vue'

// WRONG: destructuring from reactive() loses reactivity
const state = reactive({ count: 0, name: 'Vue' })
const { count, name } = state  // both are now plain values, not reactive

// RIGHT: toRefs() preserves reactivity via ref wrapping
const { count, name } = toRefs(state)
// count.value and name.value are now reactive refs synced to state

// WRONG: assigning .value to a variable loses the reactive binding
const total = computed(() => count.value * 2)
const snapshot = total.value  // plain number, no longer reactive

// RIGHT: pass the ref/computed itself, read .value only when needed
function processTotal(totalRef: Ref<number>) {
  watchEffect(() => {
    console.log('Total updated:', totalRef.value)  // reactive read
  })
}
processTotal(total)  // pass the computed ref, not its value

// WRONG: replacing reactive() with a new object breaks the proxy
const form = reactive({ email: '', password: '' })
// form = { email: 'a@b.com', password: 'x' }  // ERROR: cannot reassign const
// OR if using let: loses all reactive subscriptions on the old proxy object

// RIGHT: update properties in place
form.email = 'a@b.com'
form.password = 'x'
// OR use Object.assign() to update all properties at once
Object.assign(form, { email: 'a@b.com', password: 'x' })

9. Comparing the reactive APIs

The Vue 3 reactivity system offers several APIs for reactive data. The choice depends on the use case. This table shows the most important differences.

API For what Watch out for When preferred
ref() Any type, primitive or object Do not forget .value in JS context Default, always prefer it
reactive() Objects with many properties Destructuring breaks reactivity Form objects, never destructured
computed() Derived, cached values No side effects in the getter All derived data
watch() Observing explicit sources Does not run immediately (without immediate) When old/new value is needed
watchEffect() All read values automatically Can build up unwanted deps Initial call desired, deps unclear

Beyond these base APIs, the Vue reactivity system offers helpful utilities: toRef() creates a ref for a single property of a reactive object (instead of toRefs() for all of them). shallowRef() and shallowReactive() make only the top level reactive, useful for large objects where deeply nested reactivity is unnecessary. readonly() creates a Proxy that prevents writes and warns in the console, useful for state that should only be read. markRaw() marks an object as non-reactive so that Vue does not create a Proxy for it, sensible for external library objects such as Three.js scenes or chart instances.

Mironsoft

Vue 3 Architecture and Composition API Expertise

Fixing Vue 3 reactivity bugs in your project?

We analyze existing Vue 3 code for reactivity loss, misused computed() getters, and inefficient watch handling, and bring your Composition API up to modern standard.

Reactivity audit

Systematic analysis for reactivity loss caused by destructuring and incorrect reactive() usage

Composable refactoring

Migrating composables to the ref() standard and ensuring watchEffect cleanup

TypeScript integration

Declaring ref types, computed return types and watch source types fully and correctly

10. Summary

The Vue 3 reactivity system is an elegant implementation of dependency tracking through JavaScript Proxy. ref() is the default API for all reactive values, it is consistent, type-safe, and makes reactive values immediately recognizable through .value. reactive() is the alternative for objects treated as a unit, without destructuring. computed() caches reactive calculations and should be used for all derived data, with purely functional getters that have no side effects. watch() and watchEffect() are the tools for side effects that need to react to reactivity changes.

The most common mistakes in the Vue 3 reactivity system are reactivity loss through destructuring without toRefs() or storeToRefs(), and assigning .value to a plain variable instead of passing the ref itself. Anyone who understands the Proxy-based dependency tracking, what calls track(), what calls trigger(), and when Vue batches updates, has the mental model to systematically debug every reactivity bug instead of guessing.

Vue 3 Reactivity, The Key Points at a Glance

ref() vs reactive()

ref() as the default for every type. reactive() only for objects that are never destructured. Always destructure from reactive() with toRefs().

Using computed() correctly

Only for purely functional calculations, no side effects in the getter. Caches the result until dependencies change. For side effects: watch().

watch vs. watchEffect

watchEffect: runs immediately, automatic dependency tracking. watch: explicit sources, old/new values, no immediate run (without immediate).

Reactivity loss

Destructuring without toRefs()/storeToRefs() loses reactivity. Assigning .value instead of passing the ref loses reactivity. Always pass the ref as an object.

11. FAQ: Vue 3 Reactivity

1Why .value in JS, not in the template?
Primitives cannot be intercepted by Proxy, ref() wraps them in { value: ... }. In the template, Vue unwraps refs automatically. In JS, .value makes reactive values recognizable.
2Vue 2 vs. Vue 3 reactivity?
Vue 2: Object.defineProperty(), Vue.set() needed for new properties, array patches. Vue 3: Proxy, new properties automatically reactive, correct array index tracking.
3reactive() loses reactivity on destructuring?
Proxy only intercepts property access on the object. Destructuring copies the value, Proxy is no longer involved. Solution: toRefs().
4watch() vs. watchEffect()?
watch(): explicit sources, old/new values, no immediate call. watchEffect(): automatic tracking, immediate call, no old/new values.
5computed() vs. watchEffect()?
computed(): derived value, cached, no side effect. watchEffect(): runs side effects, no return value, no caching.
6Replacing reactive() with a new object?
No, components lose the connection to the old Proxy. Update properties in place: Object.assign(state, newValues) or individual assignment.
7What is markRaw() for?
Prevents Proxy creation for external library objects (Three.js, Chart.js). Saves performance and prevents errors on objects that are not proxy-capable.
8What is shallowRef()?
Only the .value assignment is reactive, not mutations of the object's contents. For large objects that are swapped as a whole, never partially mutated.
9Why does Vue batch updates?
Multiple state changes within the same synchronous block of code trigger only a single re-render. Use nextTick() or await nextTick() to wait for the updated DOM.
10computed() not updating as expected?
computed() only invalidates when reactive dependencies change. Common cause: a closure variable instead of a reactive value in the getter. All inputs must be ref()/reactive().