
Learn Vue 3 reactivity best practices, including ref vs reactive, computed caching, watch vs watchEffect, common pitfalls, and performance tips.
Reza Baar
March 19, 2026
Vue's reactivity system is one of its biggest strengths, but it's also where most bugs hide. If you've been writing Vue 3 with the Composition API for a while, you've probably hit a few of these walls already. In this post I have collected a group of patterns, pitfalls, and performance tricks that will save you debugging time.
By the end of this post, you'll know:
ref vs reactive (and why one is almost always the better default)computed caching works and when it recalculateswatch and watchEffectref vs reactiveBoth create reactive state. But ref is the safer default. Here's why.
reactive only works with objects. It returns a proxy of the original object, which means reassigning the variable breaks reactivity entirely. The proxy is lost, and Vue stops tracking changes.
<script setup>
import { reactive } from 'vue'
let state = reactive({ count: 0 })
// This breaks reactivity. `state` is now a plain object.
state = { count: 1 }
</script>
ref wraps any value (primitives, objects, arrays) and keeps reactivity through .value. You can reassign .value freely without losing the reactive connection.
<script setup>
import { ref } from 'vue'
const state = ref({ count: 0 })
// This is fine. Reactivity is preserved.
state.value = { count: 1 }
</script>
Quick rules:
ref for everything by defaultreactive is fine for local, complex objects you'll never reassignref is almost always what you wantconst. The ref itself shouldn't be reassigned, only .valuecomputedcomputed is for derived state. If a value can be calculated from other reactive values, it should be a computed, not a ref you manually keep in sync.
The key behavior: computed values are cached. Vue only recalculates them when one of their reactive dependencies changes. If nothing changed, accessing the computed returns the cached result instantly.
<script setup>
import { ref, computed } from 'vue'
const items = ref([
{ name: 'Shirt', price: 25 },
{ name: 'Pants', price: 40 },
])
// Only recalculates when `items` changes
const total = computed(() => items.value.reduce((sum, item) => sum + item.price, 0))
</script>
Things to keep in mind:
get/ set) exists but should be rare. If you find yourself reaching for it often, rethink your data flowwatch vs watchEffectThese two overlap but serve different purposes. Picking the right one makes your code clearer.
watchWatches specific sources you explicitly pass. Gives you both the old and new value. Does not run immediately by default.
<script setup>
import { ref, watch } from 'vue'
const searchQuery = ref('')
watch(searchQuery, (newVal, oldVal) => {
console.log(`Changed from "${oldVal}" to "${newVal}"`)
fetchResults(newVal)
})
</script>
Use watch when:
immediate: true to run on mount)watchEffectAutomatically tracks every reactive dependency used inside its callback. Runs immediately on creation.
<script setup>
import { ref, watchEffect } from 'vue'
const userId = ref(1)
const includeDetails = ref(false)
watchEffect(() => {
// Automatically re-runs when `userId` OR `includeDetails` changes
fetchUser(userId.value, includeDetails.value)
})
</script>
Use watchEffect when:
Beware: watchEffect tracks dependencies dynamically. If a dependency is behind an if statement and that branch doesn't execute on the first run, it won't be tracked until the branch runs.
watchEffect(() => {
// If `enabled` starts as false, `query` is NOT tracked yet
if (enabled.value) {
fetch(`/api/search?q=${query.value}`)
}
})
This is probably the most common mistake. When you destructure a reactive object, the resulting variables are plain values with no reactive connection.
const state = reactive({ count: 0, name: 'Vue' })
// These are just plain variables now. Not reactive.
const { count, name } = state
Fix? Use toRefs to convert each property into a ref that stays connected.
import { reactive, toRefs } from 'vue'
const state = reactive({ count: 0, name: 'Vue' })
const { count, name } = toRefs(state)
// Now `count` and `name` are refs linked to the original reactive object
count.value++ // updates state.count
Pro tip? Same thing applies to Pinia stores. Use storeToRefs when destructuring store state.
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/user'
const store = useUserStore()
const { username, email } = storeToRefs(store)
.value in scriptIn <template>, Vue auto-unwraps refs. In <script>, you need .value. This is straightforward but catches everyone at least once, especially when switching between template and script context.
By default, ref and reactive create deep reactive proxies. Every nested property is tracked. This is usually what you want, but for large objects it can be expensive.
import { shallowRef } from 'vue'
// Only `.value` assignment is tracked, not nested changes
const bigData = shallowRef({ nested: { deep: { value: 1 } } })
// This does NOT trigger updates
bigData.value.nested.deep.value = 2
// This does
bigData.value = { nested: { deep: { value: 2 } } }
Use shallowRef when you replace the whole object (like data from an API response) and don't need to track individual nested mutations.
computed over watchers for derived stateA computed is lazily evaluated and cached. A watch that writes to another ref to keep derived state in sync is doing extra work and creating unnecessary reactive overhead.
// Don't do this
const items = ref([])
const count = ref(0)
watch(items, (val) => {
count.value = val.length
})
// Do this instead
const count = computed(() => items.value.length)
shallowRef for large datasetsIf you're dealing with big arrays or deeply nested objects that get replaced wholesale (paginated API data, table rows, chart datasets), shallowRef avoids the cost of deep proxy creation.
const rows = shallowRef([])
async function loadPage(page) {
const data = await fetchRows(page)
rows.value = data // triggers reactivity
}
If you ever need to force a re-render after mutating a shallowRef's internals, use triggerRef:
import { shallowRef, triggerRef } from 'vue'
const list = shallowRef([1, 2, 3])
list.value.push(4) // won't trigger updates
triggerRef(list) // now it will
v-once renders an element once and never updates it. Good for truly static content inside a dynamic component.v-memo caches a template subtree and only re-renders when its dependency array changes. Useful in large v-for lists. <template>
<div v-for="item in list" :key="item.id" v-memo="[item.id, item.selected]">
<p>{{ item.name }}</p>
<span>{{ item.selected ? 'Yes' : 'No' }}</span>
</div>
</template>
Deeply nested reactive objects are harder to work with and more expensive to track. When possible, normalize your state. Instead of nesting objects three levels deep, use flat structures with IDs as references.
The reactivity system in Vue does a lot of heavy lifting for you, but knowing where the edges are makes a real difference. Start with ref, reach for computed before watch, and be intentional about deep vs shallow reactivity. Most performance issues and mysterious bugs in Vue apps trace back to one of the patterns covered here.
Get the latest news and updates on developer certifications. Content is updated regularly, so please make sure to bookmark this page or sign up to get the latest content directly in your inbox.

Reactivity Best Practices in Vue
Learn Vue 3 reactivity best practices, including ref vs reactive, computed caching, watch vs watchEffect, common pitfalls, and performance tips.
Reza Baar
Mar 19, 2026

Custom Errors in JavaScript: Extending Error the Right Way
Learn how to extend JavaScript’s Error class correctly, build error hierarchies, and wrap exceptions for clean, scalable error handling.
Martin Ferret
Mar 17, 2026

Lazy-loading with @defer
Master Angular lazy-loading with @defer and learn how to control triggers, placeholders, loading behaviour, and error states for standalone components.
Alain Chautard
Mar 17, 2026
We can help you recruit Certified Developers for your organization or project. The team has helped many customers employ suitable resources from a pool of 100s of qualified Developers.
Let us help you get the resources you need.
