Vue Reactivity System
Vue's reactivity system enables automatic tracking of state changes and DOM updates.
ref()
Create reactive primitive values with ref(). Access/modify via .value in JavaScript, auto-unwrapped in templates.
import { ref } from 'vue'
const count = ref(0)
console.log(count.value) // 0
count.value++<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
function increment() {
count.value++
}
</script>
<template>
<button @click="increment">{{ count }}</button>
</template>Typing refs
import { ref } from 'vue'
import type { Ref } from 'vue'
// Type inference
const year = ref(2020) // Ref<number>
// Explicit generic
const name = ref<string | null>(null)
// Ref type annotation
const id: Ref<string | number> = ref('abc')reactive()
Create reactive objects. No .value needed, but cannot reassign the entire object.
import { reactive } from 'vue'
interface State {
count: number
name: string
}
const state: State = reactive({
count: 0,
name: 'Vue'
})
state.count++ // reactiveLimitations of reactive()
- Only works with objects - not primitives
- Cannot replace entire object - loses reactivity
- Destructuring loses reactivity - use
toRefs()instead
const state = reactive({ count: 0 })
// ❌ Loses reactivity
let { count } = state
// ✅ Use toRefs
import { toRefs } from 'vue'
const { count } = toRefs(state)Recommendation
Use ref() as the primary API for declaring reactive state - it works with any value type and has consistent behavior.
Deep Reactivity
Both ref() and reactive() are deeply reactive by default:
const obj = ref({
nested: { count: 0 },
arr: ['foo', 'bar']
})
// These trigger updates
obj.value.nested.count++
obj.value.arr.push('baz')Use shallowRef() or shallowReactive() to opt out of deep reactivity for performance.
DOM Update Timing
DOM updates are batched and asynchronous. Use nextTick() to wait for updates:
import { ref, nextTick } from 'vue'
const count = ref(0)
async function increment() {
count.value++
await nextTick()
// DOM is now updated
}Ref Unwrapping Rules
- In templates: Top-level refs auto-unwrap
- In reactive objects: Refs auto-unwrap when accessed as properties
- In arrays/collections: Refs do NOT auto-unwrap
const count = ref(0)
const state = reactive({ count })
console.log(state.count) // 0 (unwrapped)
const books = reactive([ref('Vue Guide')])
console.log(books[0].value) // Need .valuecomputed()
Derive values from reactive state with automatic caching. Only re-evaluates when dependencies change.
import { ref, computed } from 'vue'
const firstName = ref('John')
const lastName = ref('Doe')
// Readonly computed
const fullName = computed(() => `${firstName.value} ${lastName.value}`)
// Writable computed
const fullNameWritable = computed({
get() {
return `${firstName.value} ${lastName.value}`
},
set(newValue: string) {
[firstName.value, lastName.value] = newValue.split(' ')
}
})Computed Best Practices
- Getters should be pure - no side effects, no mutating other state
- Don't mutate computed values - mutate the source instead
- Use computed over methods for derived data (caching benefit)
// ✅ Cached - only recalculates when items changes
const activeItems = computed(() => items.value.filter(x => x.active))
// ❌ Not cached - runs on every render
function getActiveItems() {
return items.value.filter(x => x.active)
}watch()
Explicitly watch reactive sources and run side effects when they change. Lazy by default.
import { ref, watch } from 'vue'
const id = ref(1)
watch(id, async (newId, oldId) => {
const data = await fetchData(newId)
// handle data...
})Watch Source Types
const x = ref(0)
const obj = reactive({ count: 0 })
// Single ref
watch(x, (newX) => console.log(newX))
// Getter function
watch(() => obj.count, (count) => console.log(count))
// Multiple sources
watch([x, () => obj.count], ([newX, newCount]) => {
console.log(newX, newCount)
})Watch Options
watch(source, callback, {
immediate: true, // Run immediately on creation
deep: true, // Watch nested properties
once: true, // Trigger only once (3.4+)
flush: 'post' // Run after DOM update
})watchEffect()
Automatically tracks dependencies and runs immediately. Re-runs when any tracked dependency changes.
import { ref, watchEffect } from 'vue'
const todoId = ref(1)
const data = ref(null)
watchEffect(async () => {
const response = await fetch(`/api/todos/${todoId.value}`)
data.value = await response.json()
})watch vs watchEffect
| Feature | watch | watchEffect |
|---|---|---|
| Dependency tracking | Explicit | Automatic |
| Lazy | Yes | No (immediate) |
| Access old value | Yes | No |
| Best for | Specific sources | Multiple dependencies |
Watcher Cleanup (3.5+)
Cancel stale async operations:
import { watch, onWatcherCleanup } from 'vue'
watch(id, async (newId) => {
const controller = new AbortController()
fetch(`/api/${newId}`, { signal: controller.signal })
onWatcherCleanup(() => controller.abort())
})Stopping Watchers
const stop = watch(source, callback)
const stop2 = watchEffect(() => { /* ... */ })
// Stop manually
stop()
stop2()
// Pause/Resume (3.5+)
const { stop, pause, resume } = watchEffect(() => { /* ... */ })<!-- Source references:
- https://vuejs.org/guide/essentials/reactivity-fundamentals.html
- https://vuejs.org/guide/essentials/computed.html
- https://vuejs.org/guide/essentials/watchers.html
- https://vuejs.org/api/reactivity-core.html
-->