Skip to content
Deep diveAdvanced Vue

Watchers in depth: immediate, deep, flush and cleanup

watch looks simple until it does not fire, fires too often, or reads the DOM a frame too early. Each of those has one option behind it, and Vue 3.5 added three more tools to the box.

vuejs.school team15 Apr 20256 min readVue 3.5

A watcher runs a function when reactive data changes. That one sentence hides five decisions: what counts as the source, whether changes inside nested objects count, whether to run once at the start, when in Vue's update cycle to run, and what to do with unfinished work from the previous run. Each one has its own argument or option. Get the first wrong and the watcher never fires; get the last wrong and an old request can overwrite a new one.

The ts blocks below go inside a component's <script setup lang="ts">.

Choose what to watch

The first argument to watch is the source, and it can take four shapes:

import { reactive, ref, watch } from 'vue'

const query = ref('')
const page = ref(1)
const filters = reactive({ category: 'all', inStock: false })

// a ref
watch(query, (value) => console.log('query is', value))

// a getter: any function that returns a value
watch(() => `${query.value}-${page.value}`, (key) => console.log('key is', key))

// an array of sources, with arrays of new and old values
watch([query, page], ([q, p], [oldQ, oldP]) => console.log(q, p, oldQ, oldP))

// a reactive object, watched deeply by default
watch(filters, () => console.log('a filter changed'))

The callback receives the new value, the old value and a third argument, onCleanup, covered below.

watchEffect is the other form. It takes one function, runs it immediately, and records every reactive value the function reads while it runs synchronously (reads after an await are not tracked). It re-runs when any of them changes. Use watch when you want to name the source and see the old value; use watchEffect when the function itself makes the dependencies obvious.

Fix the watcher that never fires

The most common watcher bug throws no runtime error and fails no test: the watcher just never runs. Here it is in the script of a sign-up form:

components/SignupForm.vueBreaks
import { reactive, watch } from 'vue'

const form = reactive({ email: '', agreed: false })

watch(form.email, (value) => {
  checkAvailability(value)
})

form.email is evaluated before watch is called, so what reaches watch is the string '', not anything reactive.

Vue does warn, in development only: Invalid watch source:, then the value you passed, then A watch source can only be a getter/effect function, a ref, a reactive object, or an array of these types. That warning is easy to miss. Type checking is more reliable: with lang="ts", it reports No overload matches this call on that line.

The fix is to pass a getter, so that Vue reads the property itself, inside a function it tracks:

components/SignupForm.vueWorks
import { reactive, watch } from 'vue'

const form = reactive({ email: '', agreed: false })

watch(() => form.email, (value) => {
  checkAvailability(value)
})

The same mistake has a 3.5 variant. With reactive props destructure, const { userId } = defineProps<{ userId: number }>() gives you a userId that the compiler rewrites to props.userId, so watch(userId, ...) would pass a plain number. This time the compiler catches it: the build fails and tells you to write watch(() => userId, ...). The rule covers both cases: watch a ref or a getter, never a plain value.

Watch nested changes with deep

watch is shallow by default when the source is a getter or a ref holding an object: the callback fires when the value is replaced, not when something inside it changes. A reactive object passed directly is the exception, and it is watched deeply. The deep option makes a getter or ref behave the same way:

import { reactive, watch } from 'vue'

const settings = reactive({
  theme: 'light',
  editor: { fontSize: 14, wrap: true },
})

// fires when settings.editor is replaced, not when fontSize changes
watch(() => settings.editor, save)

// fires on any nested change, including fontSize
watch(() => settings.editor, save, { deep: true })

// 3.5+: only the first level, so theme and a replaced editor object count,
// a change to editor.fontSize does not
watch(() => settings, save, { deep: 1 })

Deep watching has two catches:

  • The new and old values are the same object, because it was changed in place rather than replaced, so you cannot compare them.
  • Vue walks every nested property each time the watcher runs. That is cheap for a settings object and expensive for a list of ten thousand rows.

The numeric form added in 3.5 is the middle ground. deep: 1 tracks the object's own keys without looking inside their values, which suits an object whose nested values you replace rather than edit.

Run right away with immediate, or one time with once

By default a watcher is lazy: nothing runs until the source changes. Two options change that.

immediate: true runs the callback once right away, with undefined as the old value, and then again on every change. It is the standard shape for loading data that depends on a prop:

watch(() => props.userId, (id) => load(id), { immediate: true })

With immediate, watch behaves much like watchEffect, which also runs at once. The difference is the one above: watch names its source and passes the old value, while watchEffect tracks whatever its function reads.

once: true, available since 3.4, runs the callback on the first change and then stops the watcher. It fits a one-off reaction, such as hiding a hint the first time the user types.

Choose when the callback runs with flush

Vue queues watcher callbacks, so several changes in one tick (one synchronous run of your code) lead to one call. The flush option says when the callback runs relative to the DOM update.

The default is 'pre': the callback runs after the parent components have updated and before this component's DOM is updated. That suits most watchers, but a callback that reads this component's DOM sees the old DOM.

flush: 'post' runs the callback after Vue has updated the DOM. Use it whenever the callback measures or scrolls. Here is a message list that keeps the newest message in view:

components/MessageList.vue
<script setup lang="ts">
import { useTemplateRef, watch } from 'vue'

const props = defineProps<{ messages: string[] }>()
const list = useTemplateRef<HTMLUListElement>('list')

watch(() => props.messages.length, () => {
  const el = list.value
  if (el) el.scrollTop = el.scrollHeight
}, { flush: 'post' })
</script>

<template>
  <ul ref="list">
    <li v-for="(message, index) in messages" :key="index">{{ message }}</li>
  </ul>
</template>

useTemplateRef('list') gives you the <ul> that has ref="list". Its value is null until the list is mounted, so the callback checks el first.

With the default 'pre' timing this scrolls to the height the list had before the new <li> was rendered, so it always stops one message short. With 'post' the new item exists when scrollHeight is read. watchPostEffect is the shorthand for a watchEffect with this flush.

flush: 'sync' skips the queue: the callback runs immediately on every single reactive change, with no batching. It is right for a simple boolean that must be mirrored instantly, and wrong for anything that changes many times in one tick, such as an array being filled in a loop: the callback would run once per push. watchSyncEffect is its shorthand. If you cannot say which problem 'sync' solves for you, leave it out.

Cancel stale requests with onWatcherCleanup

A watcher that starts a request has a race in it. Change userId from 1 to 2 quickly, and the response for user 1 can arrive after the response for user 2 and overwrite it. Vue 3.5 added onWatcherCleanup to register a function that cancels the previous run's work:

import { onWatcherCleanup, ref, watch } from 'vue'

const userId = ref(1)
const profile = ref<{ name: string } | null>(null)

watch(userId, async (id) => {
  const controller = new AbortController()
  onWatcherCleanup(() => controller.abort())

  try {
    const response = await fetch(`/api/users/${id}`, { signal: controller.signal })
    profile.value = await response.json()
  } catch (error) {
    if (!controller.signal.aborted) throw error
  }
}, { immediate: true })

The function you register runs right before the watcher runs again, and also when the watcher stops, which includes the component unmounting.

AbortController is the browser's way to cancel a fetch. Calling abort() makes the pending fetch reject, and the catch swallows that one rejection while letting real errors through.

onWatcherCleanup must be called synchronously, before the first await. After an await there is no "current watcher" for it to attach to, so Vue warns in development and the cleanup never runs. To register cleanup later in an async callback, use the older form: the callback's third argument, (id, oldId, onCleanup), is bound to this watcher and works at any point. watchEffect receives the same function as its first argument.

Pause, resume and stop a watcher

Since 3.5, watch and watchEffect return a handle with three methods: pause(), resume() and stop(). Calling the handle itself is the same as stop(). Here an autosave watcher is paused while a template fills the draft:

import { reactive, watch } from 'vue'

const draft = reactive({ title: '', body: '' })
const autosave = watch(draft, () => save(draft))

async function loadTemplate(id: string) {
  autosave.pause()
  const template = await fetchTemplate(id)
  draft.title = template.title
  await new Promise(resolve => setTimeout(resolve, 0))
  draft.body = template.body
  autosave.resume()
}

pause() keeps the watcher but holds back its callback. Changes are not lost: if the source changed while paused, the callback runs once after resume(). Here that is a single save of the finished draft, with no save of the half-filled one in between. The setTimeout stands in for any wait between the two assignments. Vue already batches synchronous changes into one callback, so you only need pause() when the changes span several ticks.

stop() is for watchers Vue cannot stop for you. A watcher created synchronously in <script setup> is tied to the component and stops when it unmounts. A watcher created later, in a setTimeout callback or after an await inside a function, is not, and it will keep running after the component is gone unless you call its handle. The simplest way to avoid that is to create every watcher at the top level of the script and put the condition inside the callback.

Check it stuck3 questions from the Watchers in depth quiz.
Practice now
Was this helpful?

Keep reading