Watchers

This section documents the current Vue 3.x release line as published at the official Vue.js documentation, which is the reference these pages are written and verified against. No specific patch version is pinned. The Composition API with <script setup> is the authoring style used throughout; the Options API is shown only as a contrast.

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production, since Vue and its ecosystem iterate quickly.

This section’s bibliography lists the reference material consulted while preparing these pages.

A watcher runs a side effect when reactive state changes — fetching data, imperative DOM work, talking to a non-reactive library, logging. For deriving a value from state, use a computed property instead. The reference is Watchers and the API entries for watch and watchEffect.

watch

watch(source, callback, options?). The source is a ref, a reactive object, a getter function, or an array of those; the callback receives the new and previous values.

<script setup>
import { ref, watch } from 'vue'

const question = ref('')
const answer = ref('')

// source: a ref
watch(question, async (newQuestion, oldQuestion) => {
  if (!newQuestion.includes('?')) return
  answer.value = 'Thinking...'
  const res = await fetch('https://yesno.wtf/api')
  answer.value = (await res.json()).answer
})
</script>

A single property of a reactive object is not a valid source on its own (it evaluates to a plain value) — wrap it in a getter:

const state = reactive({ count: 0 })

// watch(state.count, ...)          // wrong -- passes a number
watch(() => state.count, (count, prev) => {
  console.log(count, prev)
})

Multiple sources — pass an array; the callback gets arrays of new and old values:

watch([fooRef, () => bar.value], ([foo, bar], [prevFoo, prevBar]) => {
  /* ... */
})

deep

Passing a reactive object directly makes the watcher implicitly deep — it fires on nested mutation, though newValue and oldValue are then the same object. A getter that returns an object is not deep unless you ask; deep also accepts a number to cap the traversal depth (3.5+).

watch(
  () => state.profile,
  (profile) => { /* also fires when state.profile.name changes */ },
  { deep: true }
)

immediate

Run the callback once right away, then on every change:

watch(source, callback, { immediate: true })

once

Run the callback at most once, then stop the watcher automatically (3.4+):

watch(source, callback, { once: true })

watchEffect

watchEffect(fn) runs fn immediately and re-runs it whenever any reactive value it read during that run changes. Dependencies are tracked automatically — there is no source list. See watchEffect.

const id = ref(1)
const data = ref(null)

watchEffect(async () => {
  const res = await fetch(`/api/todos/${id.value}`)
  data.value = await res.json()
})

Only synchronous reads before the first await are tracked. A ref accessed after an await does not register as a dependency.

watch vs. watchEffect

watch watchEffect

explicit source(s)

tracks whatever it reads

lazy by default (immediate opts in)

runs once immediately

gives newValue and oldValue

no previous value

callback is separate from the source

tracking and effect in one function

Prefer watch when you need the old value or want to be exact about the trigger; watchEffect when the effect touches several sources you would otherwise have to list.

Flush timing

By default the callback runs before Vue patches the DOM (flush: 'pre'). The alternatives:

  • flush: 'post' — run after the DOM update, so template refs hold the updated elements. watchPostEffect() is the shorthand for watchEffect with this option.

  • flush: 'sync' — run synchronously on every change, before batching. Inefficient; rarely the right call.

watch(source, callback, { flush: 'post' })

Cleanup

Register cleanup to run before the next callback (and when the watcher stops) — abort a stale request, clear a timer. onWatcherCleanup() (3.5+) is importable and must be called synchronously inside the effect:

import { watch, onWatcherCleanup } from 'vue'

watch(id, (newId) => {
  const controller = new AbortController()
  fetch(`/api/${newId}`, { signal: controller.signal })
  onWatcherCleanup(() => controller.abort())
})

The older form is an onCleanup function passed to the callback — the third argument for watch, the first for watchEffect:

watch(id, (newId, oldId, onCleanup) => {
  const controller = new AbortController()
  onCleanup(() => controller.abort())
})

Stopping a watcher

watch and watchEffect return a stop handle:

const stop = watchEffect(() => { /* ... */ })
// later
stop()

A watcher created synchronously in setup / <script setup> is bound to the component and stops on unmount. One created asynchronously — inside setTimeout, or after an await — is not bound and leaks unless you stop it by hand. The docs state watchers must be created synchronously to be disposed automatically.

Options API contrast

The Options API exposes watch: keyed by the watched property, with a method, a string method name, or an options object as the value. See the watch option.

export default {
  data() {
    return { question: '', form: { email: '' } }
  },
  watch: {
    question(newVal, oldVal) { /* ... */ },
    form: {
      handler(val) { /* ... */ },
      deep: true,
      immediate: true
    },
    'form.email'(val) { /* dotted path as a string key */ }
  }
}

this.$watch() is the imperative equivalent and returns a stop function.

watch vs. computed

A computed property returns a cached value and should be a pure function of other state. A watcher performs an effect and returns nothing. If you catch yourself assigning one ref inside a watcher purely to mirror another ref, you want a computed.

See also

  • Computed Properties — the cached, declarative alternative for derived state.

  • Template Refs — why flush: 'post' matters when a watcher reads a ref.

  • Lifecycle Hooks — where synchronously created watchers are disposed.