Watchers — watch and watchEffect
When to Use Watchers
Watchers let you run side effects in response to reactive data changes — things that computed properties can't do: async operations, DOM manipulation, calling external APIs, or logging.
| Feature | watch() | watchEffect() |
|---|---|---|
| Source declaration | Explicit — you specify what to watch | Automatic — tracks all accessed refs |
| Runs immediately | No (unless immediate: true) | Yes — runs on creation |
| Old value access | Yes — (newVal, oldVal) | No |
| Best for | Specific data changes, need old value | Multiple deps, immediate execution |
<template>
<div>
<input v-model="query" placeholder="Search..." />
<input v-model.number="userId" type="number" placeholder="User ID" />
<p>{{ status }}</p>
</div>
</template>
<script setup>
import { ref, reactive, watch, watchEffect, onUnmounted } from 'vue'
const query = ref('')
const userId = ref(1)
const status = ref('Ready')
const user = reactive({ name: '', email: '' })
// 1. Watch a single ref
watch(query, (newVal, oldVal) => {
console.log(`Query changed: "${oldVal}" → "${newVal}"`)
status.value = `Searching for: ${newVal}`
})
// 2. Watch with options
watch(userId, async (newId) => {
status.value = 'Loading...'
const res = await fetch(`/api/users/${newId}`)
const data = await res.json()
user.name = data.name
user.email = data.email
status.value = 'Loaded'
}, {
immediate: true, // run immediately on mount
flush: 'post', // run after DOM updates
})
// 3. Watch multiple sources
watch([query, userId], ([newQuery, newId], [oldQuery, oldId]) => {
console.log('Either changed:', newQuery, newId)
})
// 4. Watch reactive object — need getter or deep: true
const form = reactive({ name: '', email: '' })
// Watch specific property with getter
watch(() => form.name, (newName) => {
console.log('Name changed:', newName)
})
// Watch entire reactive object (deep)
watch(form, (newForm) => {
console.log('Form changed:', newForm)
}, { deep: true })
// 5. watchEffect — auto-tracks dependencies
const stop = watchEffect(() => {
// Automatically tracks query.value and userId.value
document.title = `${query.value} | User ${userId.value}`
console.log('Effect ran')
})
// 6. watchEffect with cleanup
watchEffect((onCleanup) => {
const timer = setTimeout(() => {
console.log('Debounced:', query.value)
}, 500)
onCleanup(() => clearTimeout(timer)) // cleanup before next run
})
// 7. Stop a watcher manually
onUnmounted(() => stop()) // stop watchEffect when component unmounts
</script>
<template>
<div>
<input v-model="searchQuery" placeholder="Search users..." />
<p v-if="loading">Searching...</p>
<ul v-else>
<li v-for="user in results" :key="user.id">{{ user.name }}</li>
<li v-if="results.length === 0 && searchQuery">No results</li>
</ul>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
const searchQuery = ref('')
const results = ref([])
const loading = ref(false)
// Debounced search with watch + cleanup
watch(searchQuery, (newQuery, _, onCleanup) => {
if (!newQuery.trim()) {
results.value = []
return
}
loading.value = true
// AbortController to cancel previous request
const controller = new AbortController()
const timer = setTimeout(async () => {
try {
const res = await fetch(`/api/users?q=${newQuery}`, {
signal: controller.signal
})
results.value = await res.json()
} catch (err) {
if (err.name !== 'AbortError') console.error(err)
} finally {
loading.value = false
}
}, 400) // 400ms debounce
// Cleanup: cancel request and clear timer if query changes
onCleanup(() => {
clearTimeout(timer)
controller.abort()
loading.value = false
})
})
</script>
Ready to Level Up Your Skills?
Explore 500+ free tutorials across 20+ languages and frameworks.