A hands-on guide to understanding Vue 3’s reactivity system. Learn how ref, reactive, computed and watch work together to keep your UI automatically in sync with your data. Includes practical examples, gotchas and best practices for mastering reactivity in Vue.
If you’ve ever wondered why Vue feels so effortless, why your UI just knows when to update, it all boils down to one thing: reactivity. Vue’s reactivity system is the magic behind its simplicity. It turns plain JavaScript data into a living, self-updating state that automatically keeps your UI in sync with your logic.
In this guide, we’ll go beyond “it just works” and unpack how Vue 3’s reactivity actually functions, what makes it so intuitive, what’s happening under the hood and how you can use it effectively in your own components.
Before Vue (and frameworks like it), updating the UI meant manually syncing your data and the DOM. Change a value? You’d have to find the element, update the text and keep track of what’s what.
Vue’s reactivity flips that process on its head. Now, your data becomes the single source of truth, and Vue automatically keeps your UI in sync with it. Change the data, and the DOM reacts. That’s the essence of reactivity.
Here’s a quick taste:
import { reactive } from 'vue'
const state = reactive({ count: 0 })
function increment() {
state.count++
// The DOM updates automatically
}
For example, after you make state reactive, you no longer need to manually update the DOM; Vue handles this using dependency tracking.
Behind the scenes, Vue 3’s reactivity system is powered by modern JavaScript Proxies. When you make an object reactive, Vue wraps it in a proxy that “intercepts” reads and writes:
Let’s walk through the main tools Vue provides to make your data reactive and useful.
Use reactive() when working with objects, arrays, or anything with multiple properties.
import { reactive } from 'vue'
const user = reactive({
name: 'David',
age: 25
})
user.age++ // reactivity in action
Every nested property inside reactive() is tracked automatically, giving you deep reactivity out of the box.
Objects work great with reactive, but what about primitives like numbers, strings or booleans?
That’s where ref() comes in:
import { ref } from 'vue'
const count = ref(0)
function increment() {
count.value++
}
Notice value? That’s how you access the actual value inside a ref. Vue adds this layer because primitive values (like numbers or strings) can’t be wrapped in proxies directly.
The cool part: inside templates, Vue automatically unwraps refs, so you can use {{ count }} instead of{{ count.value }}` in your template.
Rule of thumb:
ref() for single values (primitives).reactive for objects and collections.Sometimes, your data depends on other data. Instead of manually updating values, let Vue handle it with computed().
import { ref, computed } from 'vue'
const price = ref(100)
const quantity = ref(2)
const total = computed(() => price.value * quantity.value)
computed properties are cached and only rerun when their dependencies change—perfect for anything derived from existing state.
watch() is your go-to for running side effects when reactive data changes—for example, saving data to localStorage or triggering an API call.
import { ref, watch } from 'vue'
const username = ref('')
watch(username, (newValue) => {
localStorage.setItem('username', newValue)
})
watchEffect works similarly but is even more automatic. It immediately runs and tracks dependencies on its own:
import { ref, watchEffect } from 'vue'
const count = ref(0)
watchEffect(() => {
console.log(`Count is now ${count.value}`)
})
If you’re not sure which one to use, start with watchEffect(). It’s simpler for quick reactions to state.
Here’s a high-level mental model of how Vue’s reactivity works:
Want to peek behind the curtain? Here’s a simplified reactivity system you can try yourself:
let value
const subscribers = new Set()
function effect(fn) {
subscribers.add(fn)
fn()
}
function set(newVal) {
value = newVal
subscribers.forEach(fn => fn())
}
// Usage
effect(() => console.log('Value is', value))
set(1) // logs "Value is 1"
set(2) // logs "Value is 2"
That’s basically what Vue does, just much more optimized and feature-rich.
Reactivity in the Composition API
The Composition API is built entirely on top of reactivity. Inside setup(), you can freely mix ref(), reactive(), computed() and watch() to manage state and logic cleanly:
<script setup>
import { reactive, computed } from 'vue'
const cart = reactive({
items: [],
})
const total = computed(() =>
cart.items.reduce((sum, item) => sum + item.price, 0)
)
function addItem(item) {
cart.items.push(item)
}
</script>
Everything inside setup() stays reactive and automatically updates your template whenever your state changes .
Here is everything in action:
<script setup>
import { reactive, computed, watch } from 'vue'
const todos = reactive({
list: [],
newTodo: ''
})
const total = computed(() => todos.list.length)
function addTodo() {
if (!todos.newTodo) return
todos.list.push(todos.newTodo)
todos.newTodo = ''
}
watch(
() => todos.list,
(newList) => localStorage.setItem('todos', JSON.stringify(newList)),
{ deep: true }
)
</script>
<template>
<div>
<input v-model="todos.newTodo" placeholder="Add a todo" />
<button @click="addTodo">Add</button>
<p>Total: {{ total }}</p>
<ul>
<li v-for="todo in todos.list" :key="todo">{{ todo }}</li>
</ul>
</div>
</template>
In this example:
reactive() manages our state objectcomputed() handles derived totalswatch() persists the dataEverything is tied together by Vue’s reactivity system. Everything stays perfectly in sync without a single manual DOM update.
Even though Vue’s reactivity feels seamless, there are a few quirks worth remembering:
const { count } = reactive({ count: 0 })
count++ // ❌ not reactive anymore
Always access properties directly from the reactive object.
.value on a reactive object, only on a ref.shallowReactive() or shallowRef() might be better for performance-heavy objects.Understanding Vue’s reactivity is like unlocking the framework’s secret language. Once you understand how data flows and updates behind the scenes, everything from state management to the Composition API starts to click.
As an add-on, take a look at these best practices for working with reactivity:
computed over watch for derived state..value access—templates handle that for you.In addition, here are tips for debugging reactivity when things don’t seem to update as expected:
isReactive() or isRef() to check what’s reactive.toRaw() if you need the original, unwrapped object (for logging or external libraries).If you’ve enjoyed this deep dive, stick around—this article in this Vue Basics series explores how Vue’s lifecycle hooks tie into reactive state and when to tap into them for precise control over your components.
David Adeneye is a software developer and a technical writer passionate about making the web accessible for everyone. When he is not writing code or creating technical content, he spends time researching about how to design and develop good software products.