Vue Reactivity Pitfalls: What trips everyone up
How Vue 3 reactivity works internally — and why destructuring, wrong watch targets, and composable boundaries keep causing subtle bugs.
How Vue 3 reactivity works internally
Before you can understand the pitfalls, you need to understand the mechanism. Vue 3 builds reactivity on ES6 Proxy.
When you call reactive({ count: 0 }), Vue creates a proxy around the object. Every read access on a property registers the current "tracking instance" (a computed or a watcher) as a dependency — that's track. Every write access triggers all registered dependencies — that's trigger.
const state = reactive({ count: 0 })
watchEffect(() => {
console.log(state.count) // track: this watcher depends on state.count
})
state.count++ // trigger: watcher re-executes
ref works similarly, but wraps primitive values in an object ({ value: T }) so the proxy has something to attach to. Primitives like number, string, or boolean have no properties — without the wrapper object trick, the proxy would have nothing to observe.
That's the reason for .value. It's not an arbitrary API design choice — it's a technical necessity.
The .value trap: why destructuring breaks reactivity
The most common mistake with ref:
const count = ref(0)
const { value } = count // ← loses reactivity!
watchEffect(() => {
console.log(value) // fires only once — at creation time
})
count.value++ // ← no re-render
After destructuring, you have an ordinary number in value — no proxy anymore. Vue can't observe changes.
The same applies to reactive objects:
const user = reactive({ name: 'Alice', age: 30 })
const { name, age } = user // ← both lose reactivity
watchEffect(() => {
console.log(name) // static snapshot, never updated
})
user.name = 'Bob' // ← name is not updated
This isn't a Vue quirk — it's fundamental JavaScript. Destructuring copies values. Reactivity lives in the proxy object, not in the copied values.
The fix for objects is toRefs:
const user = reactive({ name: 'Alice', age: 30 })
const { name, age } = toRefs(user) // ← each one is now a Ref
// name.value and age.value react to changes on user
watch on reactive properties — what fires, what doesn't
A common misconception: what exactly do you pass to watch?
const state = reactive({ count: 0 })
// WRONG: passes the value (0), not the reactive source
watch(state.count, (newVal) => {
console.log(newVal) // never fires on changes
})
// RIGHT: pass a getter function
watch(() => state.count, (newVal) => {
console.log(newVal) // fires correctly
})
// RIGHT: for refs, pass the ref instance directly
const count = ref(0)
watch(count, (newVal) => {
console.log(newVal) // fires correctly
})
watch expects either a ref instance, a reactive object, or a getter function. Passing a primitive value (state.count) is equivalent to watch(0, ...) — Vue doesn't know where that value came from.
For deep observation of objects:
const user = reactive({ address: { city: 'Vienna' } })
// Only shallow — doesn't fire on changes to address.city
watch(user, callback)
// Deep observation — fires on all nested changes
watch(user, callback, { deep: true })
// Or target the specific property
watch(() => user.address.city, callback)
watchEffect and the infinite loop problem
watchEffect is elegant: it runs a function and automatically tracks all reactive dependencies read inside it. No need to manually declare sources.
const count = ref(0)
const doubled = ref(0)
watchEffect(() => {
doubled.value = count.value * 2 // reads count, writes doubled
})
The problem arises when you read and write to the same reactive source in the same watchEffect function:
const items = ref<number[]>([])
watchEffect(() => {
// INFINITE LOOP: reads items.value (→ track) and writes items.value (→ trigger)
items.value = [...items.value, items.value.length]
})
Vue 3 has some safeguards against certain loops, but the ground rule is: watchEffect may write to things it doesn't read. As soon as you write to a ref that you also read in the same function, an infinite loop is a real risk.
In these cases, watch with explicit sources is the safer choice, since you precisely control what triggers the watcher.
The diamond dependency problem in computed chains
Vue's computed values are synchronous and lazily evaluated. This leads to a subtle timing issue in deeply nested dependency graphs.
const a = ref(0)
const b = computed(() => a.value + 1)
const c = computed(() => a.value + 2)
const d = computed(() => b.value + c.value)
watchEffect(() => {
console.log(d.value) // when does this execute?
})
a.value = 10
With the diamond problem (A → B, A → C, B + C → D), it could happen that d is briefly calculated with a "stale" value of b or c before all dependencies have been updated. Vue 3 solves this through the scheduler: updates are batched and applied in the correct order before the DOM is updated.
This also means: computed values inside watchEffect are always consistent, because Vue resolves all pending computed invalidations before executing watchers.
The real risk is writing computed values with side effects:
// WRONG: computed with side effect
const expensiveData = computed(() => {
fetchSomething() // ← side effect in computed!
return a.value * 2
})
Computed values must not have side effects. They are pure transformations. For anything with side effects: watch or watchEffect.
toRef vs toRefs vs reactive — when to use what
These three are often confused. The differences:
toRef creates a single ref from a property of a reactive object:
const user = reactive({ name: 'Alice', age: 30 })
const nameRef = toRef(user, 'name')
nameRef.value = 'Bob' // ← updates user.name
console.log(user.name) // 'Bob'
toRef is ideal when you want to pass a single property as an argument to a function or composable that expects a ref.
toRefs does the same for all properties at once:
const user = reactive({ name: 'Alice', age: 30 })
const { name, age } = toRefs(user) // ← safe destructuring
name.value = 'Bob'
console.log(user.name) // 'Bob'
toRefs is the right choice when returning a reactive object from a composable and allowing the caller to destructure its properties.
reactive is for objects where you don't want .value everywhere:
const state = reactive({
user: null as User | null,
loading: false,
error: null as Error | null,
})
// No .value needed
state.loading = true
state.user = fetchedUser
Rule of thumb: Primitives → ref. Objects → reactive or ref (both work). Composable return values → refs or toRefs(reactive(...)).
shallowRef / shallowReactive for performance
Vue does deep tracking by default. With large data structures — arrays of thousands of objects, deep state trees — this can cause performance issues.
shallowRef only observes the .value reference itself, not the contents:
const bigList = shallowRef<Item[]>([])
// Changing the reference → trigger
bigList.value = [...bigList.value, newItem]
// Mutation → no trigger (Vue doesn't notice)
bigList.value.push(newItem) // ← does NOT work reactively
shallowReactive only observes top-level properties:
const state = shallowReactive({
user: { name: 'Alice' }, // changes to user.name → no trigger
count: 0, // changes to count → trigger
})
When shallow makes sense: large data structures that are rarely mutated; data that you replace entirely rather than mutating incrementally; or external data whose depth you don't control.
triggerRef for manual invalidation
Sometimes you need to force Vue to notice changes — for example when directly mutating into a shallowRef object:
const list = shallowRef<Item[]>([])
function addItem(item: Item) {
list.value.push(item) // mutation — Vue doesn't notice automatically
triggerRef(list) // manually trigger
}
triggerRef is also useful when integrating external libraries that directly mutate reactive state without Vue knowing about it.
Without triggerRef, views would never update even though the data is correct. With triggerRef, you signal to Vue: "I've changed something — please re-render."
Composables: sharing reactivity across component boundaries correctly
This is where one of the most common architectural traps lies. When you define state in a composable, you need to decide: should this state be global (shared) or per component instance?
// SHARED STATE: count exists once for the entire app
const count = ref(0) // ← outside the function
export function useCounter() {
return { count }
}
// INSTANCE STATE: every component calling useCounter gets its own count
export function useCounter() {
const count = ref(0) // ← inside the function
return { count }
}
This is not a Vue rule, but JavaScript scoping. But it's a frequent bug: you expect instance state, but get shared state because the ref was defined outside the function.
For genuine shared state in Nuxt: useState:
// Nuxt useState: SSR-safe, hydration-aware
const count = useState('counter', () => 0)
useState is like ref, but with SSR hydration: the server state is correctly transferred to the client without hydration mismatches — a problem that can occur with naive ref usage outside composables.
Reactivity in Vue is not magic — it's a tracking system with clear rules. When something doesn't react, ask yourself: does my code read the reactive value through the proxy? If not, the system won't pick it up.
