Vue Reactivity Explained: ref, reactive, and the Proxy Magic
Vue's reactivity is the most elegant part of the framework, and the part most beginners misunderstand. You have probably seen code like this, copied it from Stack Overflow, and been vaguely happy when it worked. But you did not know why it worked. That is the gap this article is for.
By the end, you will understand what ref() and reactive() actually do, why Vue 3 switched away from the Options API's data() function for the Composition API, and how the reactivity engine tracks your variables and re-renders the right components at the right time.
The mental model: a spreadsheet that watches itself
The easiest way to think about Vue reactivity is a spreadsheet. Each cell holds a value. When you change one cell, every cell that depends on it automatically updates. You do not have to tell the spreadsheet "now recalculate." It just does it.
Vue does the same thing for your component state. When you change a reactive variable, every place in your template that uses that variable re-renders. You do not have to call setState() or trigger anything. You change the variable. Vue handles the rest.
The reason this works is called a Proxy. Vue 3 wraps every reactive object in a JavaScript Proxy. When you read a property, Vue records that you read it. When you write a property, Vue finds everything that read it and schedules them for re-render. That is the whole engine. Let me show you how it feels from the inside.
The two ways to make a reactive variable
Vue 3 gives you two functions. ref() for primitives and single values, reactive() for objects. They feel different in code but they do the same thing under the hood.
ref() for primitives
import { ref } from 'vue'
const count = ref(0)
console.log(count.value) // 0 - notice the .value
count.value = 5 // to write, also use .valueThe .value looks weird at first. It is there because primitives in JavaScript cannot be made reactive directly. A number is just a number. It does not have a place to attach tracking information. So Vue wraps the number in an object with a value property, and the Proxy is on the wrapper.
In your template, you do not need .value. The template compiler unwraps refs for you automatically:
<template>
<button @click="count++">Count: {{ count }}</button>
</template>
This works because the compiler knows count is a ref. It rewrites the template to count.value at build time. You get the cleaner syntax in the template, the more explicit .value in the script.
reactive() for objects
import { reactive } from 'vue'
const user = reactive({ name: 'Hadi', age: 30 })
console.log(user.name) // 'Hadi' - no .value needed
user.age = 31 // direct assignment worksFor objects, there is no .value. You read and write properties directly. The Proxy wraps the whole object. When you read user.name, Vue records the dependency. When you write user.name, Vue marks it dirty.
So which one do you use? Convention in the Vue community is: ref() for everything, because it works in more situations. reactive() is fine for objects you are sure you will never replace wholesale. If you find yourself writing user = reactive({...}) to swap the whole object, that breaks reactivity. Use ref instead.
What "tracking" actually means
When Vue runs your component, it walks through the setup function and the template. Every time it hits a reactive read, it adds the current "effect" to a list of dependencies for that variable. An effect is basically a piece of work that should re-run when a variable changes.
Then, when you write to a reactive variable, Vue looks at the dependency list for that variable. It schedules all the effects to re-run. Effects include template re-renders, computed values, and watchers.
You do not have to manage this. Vue does it for you. But knowing it exists explains a few weird things that come up in practice.
Three gotchas that bite everyone
Gotcha 1: destructuring breaks reactivity
const user = reactive({ name: 'Hadi', age: 30 })
const { name } = user
// `name` is now a plain string. It is not reactive.When you destructure, you pull the value out of the reactive object. The Proxy magic only works as long as you are reading through the wrapper. If you need to destructure, use toRefs():
import { toRefs } from 'vue'
const user = reactive({ name: 'Hadi', age: 30 })
const { name, age } = toRefs(user)
// name and age are now refs. They stay reactive.Gotcha 2: replacing a reactive object breaks reactivity
let user = reactive({ name: 'Hadi' })
user = reactive({ name: 'Zara' }) // assignment to `let` works, but the original
// ref-holding variable lost the linkThis is a subtle one. The Proxy only tracks the original object. If you replace the whole object, the original component (or other code) that was holding a reference to the old object will not see the new one. The fix: never replace a reactive object. Update its properties instead.
Gotcha 3: pushing to an array works, but only with reactive() not with ref()
const items = ref([])
items.value.push('new') // works - push is on the .value array
items.value = [...items.value, 'new'] // also works - replaces the whole array
With ref([]), the array is wrapped. You can use array methods on items.value (push, pop, splice, etc.) and they all stay reactive. The Proxy intercepts those method calls. You can also replace the whole array; that also works because you are reassigning items.value, which the ref itself is tracking.
Just be aware: if you pass items.value to a function, that function gets the array, not the ref. If the function does arr.length = 0 to clear it, Vue still notices because length changes go through the Proxy.
computed(): cached reactivity
Sometimes you have a value that depends on other reactive values. You can use a computed():
import { ref, computed } from 'vue'
const price = ref(100)
const quantity = ref(2)
const total = computed(() => price.value * quantity.value)
console.log(total.value) // 200The total is a ref. Its value is recalculated only when price or quantity changes. If you read total.value five times in a row without any dependency changing, the function inside only runs once. Computed values are cached.
This is better than a method, which would re-run every time. Computed is what you want for derived state.
watch(): side effects on change
Sometimes you need to do something because a value changed, not just compute a new value. That is a watch:
import { ref, watch } from 'vue'
const search = ref('')
watch(search, (newValue, oldValue) => {
console.log(`search changed from "${oldValue}" to "${newValue}"`)
fetchResults(newValue)
})Watchers do not return values. They run side effects: API calls, logging, DOM manipulation, whatever. By default, watchers run after the change has been applied to the DOM.
You can watch multiple sources, watch nested properties with a deep watcher, debounce the trigger, and a dozen other things. Start with the basic form. Add the options as you need them.
Putting it all together
Here is a small component that uses everything we covered. It is a counter with a label that changes color based on the value.
<script setup>
import { ref, computed } from 'vue'
const count = ref(0)
const color = computed(() => {
if (count.value > 10) return 'red'
if (count.value < 0) return 'blue'
return 'gray'
})
</script>
<template>
<div>
<p :style="{ color: color }">Count: {{ count }}</p>
<button @click="count++">Increment</button>
</div>
</template>
That is the whole component. The counter updates when you click, the color is computed from the count, and the template re-renders whenever either changes. No subscriptions, no manual re-renders, no event emitters. Just data and a template.
That is the Vue reactivity model. It is one of the cleanest reactivity systems in any framework. Once it clicks, you will find yourself reaching for ref and computed for almost every stateful problem, and the rest of Vue will feel like a natural extension of these two primitives.
Frequently asked questions
Should I use the Options API or the Composition API?
Composition API for new code. It is more flexible, scales better to large components, and makes it easier to share logic between components. The Options API is fine for small components and is easier to read if you come from Vue 2; the Composition API is the future.
Do I need Pinia for state management?
For most apps, a single reactive() object exported from a module is enough. Pinia is great when you have multiple stores, devtools integration needs, and a team that benefits from a formal structure. Start simple. Add Pinia when the simple version starts to hurt.
Why is my computed value not updating?
Two common causes. First, you are mutating the value outside the reactive system (assigning a new object to a reactive() instead of updating a property). Second, the value the computed depends on is not reactive. Check both.
Related articles
← More in Frontend Craft