Watchers allow you to perform side effects in response to reactive state changes.
watchEffect
From packages/runtime-core/src/apiWatch.ts:56-61, watchEffect runs a function immediately and tracks its dependencies:
< script setup >
import { ref , watchEffect } from 'vue'
const count = ref ( 0 )
watchEffect (() => {
console . log ( `Count is: ${ count . value } ` )
// Runs immediately and whenever count changes
})
count . value ++ // Logs: "Count is: 1"
</ script >
API Signature
A function that will be run immediately and re-run when its dependencies change
Optional configuration object
A function to stop the watcher
type WatchEffect = ( onCleanup : OnCleanup ) => void
interface WatchEffectOptions {
flush ?: 'pre' | 'post' | 'sync'
onTrack ?: ( event : DebuggerEvent ) => void
onTrigger ?: ( event : DebuggerEvent ) => void
}
watch
From packages/runtime-core/src/apiWatch.ts:92-144, watch requires explicit sources to track:
Single Source
Multiple Sources
Getter Function
< script setup >
import { ref , watch } from 'vue'
const count = ref ( 0 )
watch ( count , ( newValue , oldValue ) => {
console . log ( `Count changed from ${ oldValue } to ${ newValue } ` )
})
count . value ++ // Logs: "Count changed from 0 to 1"
</ script >
API Signature
source
WatchSource<T> | WatchSource<T>[]
required
The reactive source(s) to watch
The callback function to execute when the source changes
Optional configuration object
type WatchSource < T > = Ref < T > | (() => T )
type WatchCallback < T > = (
value : T ,
oldValue : T ,
onCleanup : OnCleanup
) => void
interface WatchOptions < Immediate = boolean > {
immediate ?: Immediate
deep ?: boolean | number
flush ?: 'pre' | 'post' | 'sync'
once ?: boolean
onTrack ?: ( event : DebuggerEvent ) => void
onTrigger ?: ( event : DebuggerEvent ) => void
}
watch vs watchEffect
watchEffect - Implicit
watch - Explicit
import { ref , watchEffect } from 'vue'
const count = ref ( 0 )
const name = ref ( 'John' )
// Automatically tracks count and name
watchEffect (() => {
console . log ( ` ${ name . value } : ${ count . value } ` )
})
// Runs immediately
// Re-runs when count OR name changes
When to use which:
Use watchEffect when you need the function to run immediately and track dependencies automatically
Use watch when you need explicit control over what to watch and access to old values
Watch Options
Run callback immediately:
< script setup >
import { ref , watch } from 'vue'
const count = ref ( 0 )
watch (
count ,
( newValue ) => {
console . log ( `Count: ${ newValue } ` )
},
{ immediate: true } // Runs immediately with initial value
)
// Logs: "Count: 0"
</ script >
deep
From packages/runtime-core/src/apiWatch.ts:49-53, watch nested properties:
< script setup >
import { reactive , watch } from 'vue'
const state = reactive ({
user: {
name: 'John' ,
profile: {
age: 30
}
}
})
watch (
() => state . user ,
( newValue ) => {
console . log ( 'User changed:' , newValue )
},
{ deep: true } // Watch nested properties
)
state . user . profile . age = 31 // Triggers watch
</ script >
Deep watchers traverse all nested properties and can be expensive for large objects. Use specific getters when possible.
flush
From packages/runtime-core/src/apiWatch.ts:45-47, control when the callback runs:
< script setup >
import { ref , watch } from 'vue'
const count = ref ( 0 )
// Default: 'pre' - before component updates
watch ( count , () => {
console . log ( 'Pre-flush' )
}, { flush: 'pre' })
// 'post' - after component updates (can access updated DOM)
watch ( count , () => {
console . log ( 'Post-flush' )
}, { flush: 'post' })
// 'sync' - runs synchronously (use with caution)
watch ( count , () => {
console . log ( 'Sync' )
}, { flush: 'sync' })
</ script >
once
From packages/runtime-core/src/apiWatch.ts:52, run callback only once:
< script setup >
import { ref , watch } from 'vue'
const count = ref ( 0 )
watch (
count ,
( newValue ) => {
console . log ( 'Triggered once:' , newValue )
},
{ once: true } // Only triggers the first time count changes
)
count . value ++ // Logs
count . value ++ // Doesn't log
</ script >
watchPostEffect
From packages/runtime-core/src/apiWatch.ts:63-74, an alias for watchEffect with flush: 'post':
< template >
< div ref = "el" > {{ count }} </ div >
</ template >
< script setup >
import { ref , watchPostEffect } from 'vue'
const count = ref ( 0 )
const el = ref ( null )
watchPostEffect (() => {
// Runs after component updates
console . log ( 'Updated DOM:' , el . value ?. textContent )
})
</ script >
watchSyncEffect
From packages/runtime-core/src/apiWatch.ts:76-87, runs synchronously:
< script setup >
import { ref , watchSyncEffect } from 'vue'
const count = ref ( 0 )
watchSyncEffect (() => {
console . log ( 'Sync:' , count . value )
})
count . value ++ // Logs immediately, synchronously
</ script >
Use watchSyncEffect sparingly as it can cause performance issues. It runs synchronously, potentially multiple times in a single “tick”.
Stopping Watchers
Watchers return a stop handle:
< script setup >
import { ref , watchEffect } from 'vue'
const count = ref ( 0 )
const stop = watchEffect (() => {
console . log ( `Count: ${ count . value } ` )
})
// Later, stop watching
stop ()
count . value ++ // Won't log
</ script >
Watchers registered in setup() or <script setup> are automatically stopped when the component unmounts. Manual cleanup is only needed for watchers created asynchronously or that should stop earlier.
Cleanup Function
Register a cleanup function for side effects:
< script setup >
import { ref , watchEffect } from 'vue'
const id = ref ( 0 )
watchEffect (( onCleanup ) => {
const controller = new AbortController ()
fetch ( `/api/data/ ${ id . value } ` , { signal: controller . signal })
. then ( /* ... */ )
onCleanup (() => {
// Cancel the request if id changes before completion
controller . abort ()
})
})
</ script >
From packages/runtime-core/src/apiWatch.ts:146-242, the cleanup function is called:
Before the watcher re-runs
When the watcher is stopped
Watcher Debugging
Use debug options to trace reactivity:
< script setup >
import { ref , watch } from 'vue'
const count = ref ( 0 )
watch (
count ,
( newValue ) => {
console . log ( 'Count changed to:' , newValue )
},
{
onTrack ( e ) {
console . log ( 'Tracked:' , e )
},
onTrigger ( e ) {
console . log ( 'Triggered:' , e )
}
}
)
</ script >
Watching Reactive Objects
Watch Whole Object
Watch Specific Property
< script setup >
import { reactive , watch } from 'vue'
const state = reactive ({ count: 0 , name: 'John' })
// Automatically deep watch
watch ( state , ( newValue ) => {
console . log ( 'State changed:' , newValue )
})
state . count ++ // Triggers
state . name = 'Jane' // Triggers
</ script >
Advanced Patterns
Debounced Watch
< script setup >
import { ref , watch } from 'vue'
const searchQuery = ref ( '' )
let timeout = null
watch ( searchQuery , ( newQuery ) => {
clearTimeout ( timeout )
timeout = setTimeout (() => {
console . log ( 'Searching for:' , newQuery )
// Perform search
}, 300 )
})
</ script >
Conditional Watch
< script setup >
import { ref , watch } from 'vue'
const count = ref ( 0 )
const enabled = ref ( true )
watch (
count ,
( newValue ) => {
if ( enabled . value ) {
console . log ( 'Count:' , newValue )
}
}
)
</ script >
Watch with Async Operations
< script setup >
import { ref , watch } from 'vue'
const userId = ref ( 1 )
const userData = ref ( null )
watch ( userId , async ( newId , oldId , onCleanup ) => {
let cancelled = false
onCleanup (() => {
cancelled = true
})
const data = await fetchUser ( newId )
if ( ! cancelled ) {
userData . value = data
}
})
</ script >
Watch Implementation Details
From packages/runtime-core/src/apiWatch.ts:146-242, the doWatch function:
function doWatch (
source : WatchSource | WatchSource [] | WatchEffect | object ,
cb : WatchCallback | null ,
options : WatchOptions = EMPTY_OBJ
) : WatchHandle {
const { immediate , deep , flush , once } = options
const baseWatchOptions : BaseWatchOptions = extend ({}, options )
let isPre = false
if ( flush === 'post' ) {
baseWatchOptions . scheduler = job => {
queuePostRenderEffect ( job , instance && instance . suspense )
}
} else if ( flush !== 'sync' ) {
// default: 'pre'
isPre = true
baseWatchOptions . scheduler = ( job , isFirstRun ) => {
if ( isFirstRun ) {
job ()
} else {
queueJob ( job )
}
}
}
const watchHandle = baseWatch ( source , cb , baseWatchOptions )
return watchHandle
}
Common Use Cases
Data fetching
watch ( userId , async ( id ) => {
data . value = await fetchUser ( id )
})
Local storage sync
watch ( settings , ( newSettings ) => {
localStorage . setItem ( 'settings' , JSON . stringify ( newSettings ))
}, { deep: true })
DOM side effects
watchPostEffect (() => {
if ( isVisible . value ) {
element . value ?. focus ()
}
})
Logging and analytics
watch ( route , ( to , from ) => {
analytics . track ( 'pageview' , { from: from . path , to: to . path })
})
Best Practices
Prefer computed for derived state
Use computed properties for synchronous data transformations, not watchers. // Bad - using watch for derived state
watch ( firstName , () => {
fullName . value = ` ${ firstName . value } ${ lastName . value } `
})
// Good - using computed
const fullName = computed (() => ` ${ firstName . value } ${ lastName . value } ` )
Use cleanup functions
Always cleanup side effects that may become stale. watchEffect (( onCleanup ) => {
const timer = setTimeout (() => {}, 1000 )
onCleanup (() => clearTimeout ( timer ))
})
Avoid deep watchers on large objects
Deep watching traverses all nested properties. Use specific getters instead.
Stop watchers when needed
Manually created watchers (async) should be stopped to prevent memory leaks.
Computed Properties For derived reactive state
Reactivity Fundamentals Understanding reactive data
Lifecycle Hooks Execute code at lifecycle stages