Vue 3 reactivity: ref, reactive, computed, and watch
Understand how Vue tracks data changes, when to use ref or reactive, and the difference between computed and watch, along with common pitfalls.
How does reactivity work?
Reactivity is the heart of Vue. While a component renders, Vue records which reactive data is read. When any of that data changes, Vue schedules a re-render of only the components that need it. In Vue 3 this mechanism is built on JavaScript Proxy objects and getters/setters.
For this to work, data must be created with a reactivity API such as ref or reactive. A plain variable (let total = 0) will not trigger UI updates.
ref: the default choice
ref wraps any value, primitive or object, in an object with a .value property.
<script setup>
import { ref } from 'vue'
const title = ref('Learning Vue')
const tags = ref(['vue', 'javascript'])
function addTag(tag) {
tags.value.push(tag)
}
</script>
<template>
<h2>{{ title }}</h2>
<p>{{ tags.join(', ') }}</p>
<button type="button" @click="addTag('frontend')">Add tag</button>
</template>
Inside <script> you must write .value. In the template, top-level refs are unwrapped automatically, so you don't need .value. When a ref holds an object or array, its contents are deeply reactive too, so tags.value.push() still triggers an update.
reactive: for objects
reactive turns an object into a reactive proxy without .value:
import { reactive } from 'vue'
const form = reactive({
email: '',
password: '',
rememberMe: false,
})
form.email = 'user@example.com'
However, reactive has some limitations:
- It only works with objects, arrays,
Map, andSet, not strings or numbers. - The variable must not be replaced entirely.
form = reactive({...})breaks the connection with the UI. - Destructuring loses reactivity.
const { email } = form // email is now a plain string, not reactive
If you need to destructure, use toRefs:
import { toRefs } from 'vue'
const { email, password } = toRefs(form)
email.value = 'new@example.com' // stays in sync with form.email
Because of these limitations, the official Vue documentation recommends ref as the primary API for declaring state.
computed: cached derived values
If a value can be calculated from other data, use computed. Its result is cached and only recalculated when its dependencies change.
<script setup>
import { computed, ref } from 'vue'
const cart = ref([
{ name: 'Vue book', price: 25, qty: 1 },
{ name: 'Mouse', price: 18, qty: 2 },
])
const totalPrice = computed(() =>
cart.value.reduce((total, item) => total + item.price * item.qty, 0)
)
const formatCurrency = (amount) =>
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount)
</script>
<template>
<p>Total: {{ formatCurrency(totalPrice) }}</p>
</template>
Important rules for computed:
- No side effects: don't call APIs or modify other state inside it.
- Treat the result as read-only. If you really need a writable value, use the
computed({ get, set })form. - Compared with a method: a method runs every time the component renders, while a computed property only recalculates when its dependencies change.
watch: running side effects
Use watch when you need to do something in response to a change, such as fetching data from an API or saving to localStorage.
import { ref, watch } from 'vue'
const keyword = ref('')
const results = ref([])
watch(keyword, async (newValue, oldValue, onCleanup) => {
if (newValue.length < 3) {
results.value = []
return
}
const controller = new AbortController()
onCleanup(() => controller.abort())
const response = await fetch(`/api/articles?q=${encodeURIComponent(newValue)}`, {
signal: controller.signal,
})
results.value = await response.json()
})
onCleanup aborts the previous request if the keyword changes again before the response arrives, so stale results never overwrite fresh ones.
Some commonly used options:
watch(source, callback, { immediate: true }) // run once right away
watch(reactiveObject, callback, { deep: true }) // watch changes inside an object
watch(() => form.email, callback) // watch a single property of a reactive object
Note the last example: to watch a property of a reactive object, pass a getter function. Writing watch(form.email, ...) does not work because only a plain string is passed.
watchEffect
watchEffect runs a function immediately and automatically tracks every reactive value read inside it:
import { watchEffect } from 'vue'
watchEffect(() => {
localStorage.setItem('cart', JSON.stringify(cart.value))
})
Use watch when you want explicit control over the source being watched and access to the old value. Use watchEffect when the effect depends on many values and you don't need the old value.
DOM updates are asynchronous
Vue batches multiple changes into a single update cycle for efficiency. As a result, the DOM is not updated immediately after state changes. Use nextTick when you need to read the updated DOM:
import { nextTick, ref } from 'vue'
const isOpen = ref(false)
async function openPanel() {
isOpen.value = true
await nextTick()
document.querySelector('#panel input')?.focus()
}
Summary
| Need | API |
|---|---|
| Simple state or objects | ref |
Objects without .value |
reactive |
| Derived values | computed |
| Side effects with a specific source | watch |
| Side effects with automatic tracking | watchEffect |
Exercise
Build a temperature converter with a single ref for Celsius, then display Fahrenheit and Kelvin using computed. Add a watch that saves the latest Celsius value to localStorage.
