Rules That Keep Reactivity Working
Reactivity tracks property access, so state read too early or too late is just a value nothing can observe.
() => state.count // pass a getter or the ref itself, never state.countOne mechanism sits under all of this. When a reactive property is read inside a tracking context — a render, a computed getter, a watcher — Vue records the pair. When that property is written, Vue re-runs whatever recorded it.
Two consequences follow, and nearly every reactivity bug is one of them.
Reading early turns state into a value
const { count } = state and const n = count.value both happen once, on that line. What you hold afterwards is a number, and nothing can notice it changing because there is no property left to intercept. Pass the ref itself, or pass a getter such as () => state.count, and let the consumer read it when it needs the value.
Reading late is not tracked at all
Tracking only happens while the effect is actually running. In watchEffect(async () => { await load(); use(other.value) }) the read of other happens after the function has yielded, so it is never registered as a dependency. Read what you depend on before the first await.
The rest of the list
- Reassigning a
reactivevariable abandons the proxy that the render still holds. - A plain object you hold a direct reference to is never reactive. Nesting it in reactive state does convert it — the conversion is deep — but only when you read it back through the proxy.
- The raw object is not the proxy: writes to the original trigger nothing.
- Reading state in a
setTimeoutor an event handler is untracked. That is correct and normal — it just will not re-run anything.
Example
<!-- src/components/NameCard.vue -->
<script setup lang="ts">
import { computed, reactive, ref, toRefs } from 'vue'
const state = reactive({ first: 'Ada', last: 'Lovelace' })
// Broken: a copy taken once, so the template never sees a change.
const { first } = state
// Working: a ref that reads through to state.first's neighbour.
const { last } = toRefs(state)
const greeting = computed(() => `Hello ${state.first} ${last.value}`)
const renames = ref(0)
function rename(): void {
state.first = 'Grace'
state.last = 'Hopper'
renames.value += 1
}
</script>
<template>
<p>{{ greeting }}</p>
<p>copied once: {{ first }}</p>
<p>through toRefs: {{ last }}</p>
<p>renames: {{ renames }}</p>
<button type="button" @click="rename">rename</button>
</template>
<style scoped>
p {
margin: 0;
}
</style>When to use it
- Diagnose a component that renders once with the right data and then stops updating.
- Decide what to hand a helper function so it keeps seeing current state.
- Fix an async watcher that reacts to one of its inputs and ignores the others.
More examples
Pass the box, not the value
A helper that takes a ref can read the current value every time it is called; the same helper given count.value is stuck with the number from the call site. When you write a function that touches state, accept the ref or a getter. unref and the isRef ternary are interchangeable, and you will see both in other people's code.
// src/lib/describe.ts
import { isRef, ref, unref } from 'vue'
import type { Ref } from 'vue'
function describe(source: Ref<number> | number): string {
// unref(x) is shorthand for isRef(x) ? x.value : x — the same thing.
const value = unref(source)
return `${value} (${isRef(source) ? 'live' : 'snapshot'})`
}
const count = ref(1)
console.log(describe(count))
// Output: 1 (live)
console.log(describe(count.value))
// Output: 1 (snapshot)
count.value = 2
console.log(describe(count))
// Output: 2 (live)Tracking stops at the first await
The effect registers dependencies only while it is on the stack, which ends at the first await. This is why an async watcher appears to ignore half its inputs. Read every reactive value you depend on at the top of the function, then do the async work with the plain values you captured.
// src/lib/track-await.ts
import { ref, watchEffect } from 'vue'
const id = ref(1)
const locale = ref('en')
watchEffect(async () => {
const currentId = id.value // tracked: read before the await
await Promise.resolve()
console.log(currentId, locale.value) // locale is NOT tracked
})
// Changing locale alone never re-runs this effect.
// Fix: read locale.value above the await, next to id.value.The raw object is not the proxy
reactive returns a proxy wrapping the original, so the value is shared but the tracking is not. A write through raw is visible the next time something reads the proxy, but it triggers no render and no watcher, which produces a page that is one interaction behind. Keep the proxy and let the original go out of scope.
// src/lib/raw-vs-proxy.ts
import { isReactive, reactive } from 'vue'
const raw = { count: 0 }
const state = reactive(raw)
console.log(state === raw)
// Output: false
console.log(isReactive(raw), isReactive(state))
// Output: false true
raw.count = 5 // no interception, so nothing re-renders
console.log(state.count)
// Output: 5
Discussion