Have you ever crashed a browser with a watch that triggers an infinite loop? Or created reactive state that doesn't update when you expect? It happens to everyone. Vue 3's reactivity system is powerful, but it needs to be understood. At Meteora Web, we use it daily in our proprietary platforms – and we've seen projects slow down or break because ref(), computed, and watch were used without discipline. In this guide, we explain the why and how, with real examples you can copy immediately.
The Reactive Triad: ref, computed, and watch
Vue 3 offers three core tools for managing reactive state in the Composition API: ref() for mutable data, computed() for derived values, and watch() / watchEffect() for side effects. Each has a precise role. Using them well means more performant code, fewer bugs, and – important to us – lower maintenance costs.
ref() and shallowRef(): Choose the Right Depth
ref() makes a primitive or object reactive. For nested objects, Vue recursively tracks every property. That’s convenient but costly: modifying a deep property triggers a re-render of the entire component, even if other parts stay the same.
shallowRef() tracks only the outer reference: if you assign a new object, the component updates; if you modify an internal property, it won't. It’s ideal for immutable data or when you manually notify changes with triggerRef().
import { ref, shallowRef, triggerRef } from 'vue'
// deep ref: every internal change is tracked
const user = ref({ name: 'Mario', address: { city: 'Sciacca' } })
user.value.address.city = 'Palermo' // reactive trigger
// shallowRef: only the outer reference is reactive
const orders = shallowRef([{ id: 1, total: 50 }])
// To update, you MUST replace the array or call triggerRef
triggerRef(orders) // manually notify watchers
When to use shallowRef? When you have long lists or data that is replaced entirely (e.g., an array of orders from an API). It avoids unnecessary deep reactivity that consumes memory and CPU.
computed: Never Write Side Effects
A computed returns a derived value. It’s lazy (calculates only when needed) and cached (if dependencies don't change, it reuses the previous value). Two golden rules:
- It must have no side effects (no API calls, no logging).
- It must not mutate reactive state (only read).
import { ref, computed } from 'vue'
const price = ref(100)
const quantity = ref(2)
// ✅ correct: pure derived value, no side effects
const total = computed(() => price.value * quantity.value)
// ❌ wrong: side effect inside computed
const wrongTotal = computed(() => {
quantity.value++ // mutates state! infinite loop
return price.value * quantity.value
})
A common mistake is using computed to filter large lists inside templates. That’s fine, but if the list is huge (thousands of items), consider v-memo or manual memoization. At Meteora Web, we optimized dashboards with thousands of rows by switching from computed to non-reactive functions updated on explicit actions.
watch and watchEffect: When to Execute Side Effects
watch() observes one or more reactive sources and calls a callback when they change. You must specify the source (a getter). watchEffect() runs the callback immediately and automatically tracks all reactive dependencies used inside it.
| Feature | watch | watchEffect |
|---|---|---|
| Access to old value | Yes | No |
| Immediate execution | No (lazy) | Yes |
| Explicit dependencies | Declared | Automatic |
| Effect cleanup | Cleanup callback | Cleanup callback |
import { ref, watch, watchEffect } from 'vue'
const query = ref('')
const results = ref([])
// watch: useful for debounce or accessing old value
watch(query, async (newVal, oldVal) => {
if (newVal === oldVal) return
results.value = await fetchData(newVal)
})
// watchEffect: for logging or simple syncs
watchEffect(() => {
console.log('Query changed:', query.value)
// runs immediately, and every time query changes
})
Beware of deep watch: if you pass { deep: true } on a large object, any change to any property will fire the watch. Better to observe a specific getter:
// ❌ expensive deep watch
watch(user, handler, { deep: true })
// ✅ targeted getter
watch(() => user.value.address.city, handler)
Advanced Patterns for Performance and Reliability
Debouncing a watch for user input
If your watch fires an API call on every keystroke, you're killing UX and your API budget. Use debounce:
import { watch, ref } from 'vue'
import { debounce } from 'lodash-es' // or custom function
const search = ref('')
const debouncedFetch = debounce((query) => {
// API call
}, 400)
watch(search, (val) => {
debouncedFetch(val)
})
Cleanup with onWatcherCleanup (Vue 3.4+)
As of Vue 3.4, you can use onWatcherCleanup to cancel async operations when the watcher is triggered again or the component unmounts:
import { watch, onWatcherCleanup } from 'vue'
watch(id, (newId) => {
const controller = new AbortController()
onWatcherCleanup(() => controller.abort())
fetch(`/api/user/${newId}`, { signal: controller.signal })
})
Reactivity in Nested Structures and Performance
When working with stores (e.g., Pinia or custom reactive stores), remember that computed isn't always the answer. If you need to compute an aggregate on a list of thousands of objects, and the list rarely changes, consider a watch that updates a manual ref – avoiding reactive recalculations on every template re-render.
const products = ref([/* long list */])
const cartTotal = ref(0)
// computed would be recalculated on every template access
// but if we only need to update when the list changes:
watch(products, (newList) => {
cartTotal.value = newList.reduce((acc, p) => acc + p.price, 0)
}, { immediate: true })
At Meteora Web, we use this technique in ecommerce platforms to keep cart totals in check without unnecessary computation overhead.
Common Mistakes and How to Avoid Them
- Using ref() instead of reactive() for large objects:
refwraps the object in a proxy, but you access it with.value.reactiveis more direct but requires caution with spreads. We preferrefbecause it's explicit and works better with composition. - Forgetting .value in script setup: In templates,
refis auto-unwrapped, but inside the setup script you must use.value. Classic mistake. - WatchEffect with async effects and no race condition handling: If the callback is
async, each new invocation doesn't wait for the previous one. Use a flag or AbortController. - Computed mutates state: never do that. If you need to transform data, create a new object.
- ShallowRef without triggerRef: if you don't notify manually, the component won't update.
In Summary — What to Do Now
- Review every computed: if it contains
console.log,fetch, or mutates state, move it to a watch. - Replace ref() with shallowRef() for long arrays or data you replace entirely. Add
triggerRefwhen needed. - Use watch with a specific getter instead of deep: true for nested objects.
- Add debounce to all watches that trigger API calls in response to user input.
- Implement cleanup with
onWatcherCleanupto avoid stale responses and memory leaks.
Sponsored Protocol