Introduction
Watchers are one of Vue's most powerful yet misused features. They let you react to reactive state changes, but without proper discipline, they can leak memory, trigger race conditions, and make your codebase harder to reason about. This guide covers watchers in depth: Vue & Nuxt best practices explained—from choosing the right watch source to cleaning up stale requests—so your team can use them without bugs.
Watch sources — Refs, getters or arrays of sources
The foundation of any watcher is its source: what you're actually watching. Vue 3 gives you several options, and each has trade-offs worth understanding.
A ref is the simplest source. When you watch a ref directly, Vue tracks changes to its .value property. This works well for single, top-level reactive values.
const count = ref(0)
watch(count, (newVal, oldVal) => {
console.log(`Count changed from ${oldVal} to ${newVal}`)
})
A getter function is more flexible. It lets you compose reactive state dynamically and only recompute when dependencies change. This is especially useful when you need to watch a computed property or a slice of a nested object.
const user = reactive({ name: 'Alice', email: 'alice@example.com' })
watch(
() => user.email,
(newEmail) => {
console.log(`Email updated to ${newEmail}`)
validateEmail(newEmail)
}
)
When you need to watch multiple sources at once, use an array. The callback fires if any source changes. This avoids duplicating watch logic.
const firstName = ref('John')
const lastName = ref('Doe')
const fullName = computed(() => `${firstName.value} ${lastName.value}`)
watch([firstName, lastName], ([fName, lName]) => {
console.log(`Name is now ${fName} ${lName}`)
})
A common mistake is watching a reactive object directly without a getter. Vue won't track nested property changes unless you enable deep watching (covered next), and it's often unclear which properties actually matter to your logic.
// ❌ Not ideal—will only trigger if the object reference changes
watch(user, () => {
console.log('User changed')
})
// ✅ Better—explicit about what you're watching
watch(() => user.email, () => {
console.log('Email changed')
})
Use getters as your default. They're explicit, efficient, and make it obvious to future readers what state triggers the callback.
Deep watching — deep: true for nested changes — use sparingly
Sometimes you need to detect changes anywhere inside a nested object or array. The deep: true option tells Vue to recursively check every property.
const config = reactive({
database: {
host: 'localhost',
port: 5432
},
cache: {
enabled: true
}
})
watch(config, (newConfig) => {
console.log('Config changed', newConfig)
}, { deep: true })
This is powerful but comes with a cost: Vue must traverse the entire object tree on every change, which is slow for large structures. In high-frequency updates (like animations or form inputs with many fields), this can become a performance bottleneck.
The alternative is to watch specific nested paths using getter functions. This is almost always better because it's explicit and only recomputes when that specific value changes.
// ✅ Preferred—only watches the port
watch(() => config.database.port, (newPort) => {
console.log(`Port changed to ${newPort}`)
reconnectDatabase(newPort)
})
In Nuxt, when you're watching route parameters or query strings that can change frequently, avoid deep watching the entire route object. Instead, watch specific parts.
// In a Nuxt page component
const route = useRoute()
// ❌ Avoid—deep watches the entire route
watch(route, () => {
fetchData()
}, { deep: true })
// ✅ Better—only responds to query changes
watch(() => route.query.page, (newPage) => {
fetchData(newPage)
})
Reserve deep: true for rare cases where you genuinely need to watch an entire structure and performance is not a concern. Most production codebases can eliminate it in favor of targeted getters.
Immediate — immediate: true runs once on setup
By default, a watcher doesn't fire until its source changes. But sometimes you want the callback to run immediately when the component mounts. That's where immediate: true comes in.
const userId = ref(null)
watch(userId, (newId) => {
fetchUserData(newId)
}, { immediate: true })
With this config, if userId already has a value at setup time, fetchUserData runs right away. This is handy for initialization patterns: fetch data when a URL parameter is set, or sync local state to an API when a form opens.
However, immediate: true can hide bugs. If your callback assumes certain lifecycle events have fired (like component refs being mounted), it may fail. Always test the immediate run path.
In Nuxt, a cleaner alternative for data fetching is often useFetch or useAsyncData with a watcher. These built-in composables handle immediate fetches and SSR context correctly.
// Nuxt pages/product/[id].vue
const route = useRoute()
const { data: product } = await useFetch(() =>
`/api/products/${route.params.id}`
)
// Cleaner than watch + immediate for data loading
If you do use immediate: true with side effects, consider wrapping the callback logic in a separate function so you can test it independently of the watcher.
Cleanup — onWatcherCleanup cancels stale requests
This is where many production bugs hide. If you make an async call (API request, database query, timer) inside a watcher, and the source changes again before that call completes, you can end up applying the old response to new state.
Vue 3.4+ provides onWatcherCleanup to fix this. It registers a cleanup function that runs before the next watch callback or when the watcher is destroyed.
import { watch, onWatcherCleanup } from 'vue'
const userId = ref(1)
watch(userId, async (newId) => {
let isMounted = true
onWatcherCleanup(() => {
isMounted = false
})
const response = await fetch(`/api/users/${newId}`)
const user = await response.json()
if (isMounted) {
console.log(`Loaded user: ${user.name}`)
}
})
The key idea: set a flag in the cleanup function, then check that flag before applying results. If the watcher runs again, the old callback's flag is set to false, so its response is ignored.
Alternatively, use an AbortController to cancel fetch requests directly.
const userId = ref(1)
watch(userId, async (newId) => {
const controller = new AbortController()
onWatcherCleanup(() => {
controller.abort()
})
try {
const response = await fetch(`/api/users/${newId}`, {
signal: controller.signal
})
const user = await response.json()
console.log(`Loaded user: ${user.name}`)
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Fetch failed:', error)
}
}
})
In Nuxt, the composables useFetch and useAsyncData handle cleanup automatically. Use them for data loading instead of manual watchers when possible.
// Nuxt composable—handles cleanup behind the scenes
const { data, pending } = useFetch('/api/data')
If you're managing intervals, timers, or WebSocket connections in a watcher, cleanup is critical.
const pollInterval = ref(5000)
watch(pollInterval, (newInterval) => {
let intervalId
onWatcherCleanup(() => {
clearInterval(intervalId)
})
intervalId = setInterval(() => {
fetchLatestData()
}, newInterval)
})
Common mistakes
1. Watching reactive objects without a getter. This doesn't track nested changes unless you add deep: true, which is often inefficient. Use a getter instead.
2. Forgetting cleanup in async watchers. Race conditions happen silently. Always cancel in-flight requests or toggle a flag with onWatcherCleanup.
3. Overusing immediate: true. It's tempting, but it can mask lifecycle issues. If you need initial data, use useFetch or call the logic explicitly in setup.
4. Using deep: true on large objects. Performance suffers fast. Narrow down to specific properties with getters.
5. Watching route parameters without cleanup in Nuxt. If your watcher fetches data and the route changes, you risk applying old data. Always clean up.
Key takeaways
- Use getter functions as your default watch source. They're explicit and efficient.
- Avoid
deep: trueunless you have no alternative. Watch specific nested paths with getters instead. - Use
immediate: truecarefully; test that the callback works on mount. PreferuseFetchoruseAsyncDatafor data initialization in Nuxt. - Always implement cleanup for async operations. Use
onWatcherCleanupor AbortController to prevent race conditions. - When in doubt, reach for Nuxt composables. They handle common watcher patterns safely.






Comments
Loading comments…