---
title: "Watchers in depth: immediate, deep, flush and cleanup · vuejs.school"
description: "What watch and watchEffect accept, when they fire, the deep and flush options, cleanup for stale requests, and the pause and resume handle from Vue 3.5."
canonical_url: "https://vuejs.school/community/blog/watchers-in-depth"
meta:
  "og:description": "What watch and watchEffect accept, when they fire, the deep and flush options, cleanup for stale requests, and the pause and resume handle from Vue 3.5."
  "og:image": "https://vuejs.school/_og/d/a_~V2F0Y2hlcnMgaW4gZGVwdGg6IGltbWVkaWF0ZSwgZGVlcCwgZmx1c2ggYW5kIGNsZWFudXAgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_~V2F0Y2hlcnMgaW4gZGVwdGg6IGltbWVkaWF0ZSwgZGVlcCwgZmx1c2ggYW5kIGNsZWFudXA%2Cdescription_~V2hhdCB3YXRjaCBhbmQgd2F0Y2hFZmZlY3QgYWNjZXB0LCB3aGVuIHRoZXkgZmlyZSwgdGhlIGRlZXAgYW5kIGZsdXNoIG9wdGlvbnMsIGNsZWFudXAgZm9yIHN0YWxlIHJlcXVlc3RzLCBhbmQgdGhlIHBhdXNlIGFuZCByZXN1bWUgaGFuZGxlIGZyb20gVnVlIDMuNS4%2Ceyebrow_~RGVlcCBkaXZlIMK3IDYgbWluIHJlYWQ%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy93YXRjaGVycy1pbi1kZXB0aCI%2Cs_9NiXh5ZDD4Gfj9YG.png"
  "og:image:alt": "Watchers in depth: immediate, deep, flush and cleanup · vuejs.school"
  "og:image:height": "600"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "Watchers in depth: immediate, deep, flush and cleanup · vuejs.school"
  "twitter:image": "https://vuejs.school/_og/d/a_~V2F0Y2hlcnMgaW4gZGVwdGg6IGltbWVkaWF0ZSwgZGVlcCwgZmx1c2ggYW5kIGNsZWFudXAgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_~V2F0Y2hlcnMgaW4gZGVwdGg6IGltbWVkaWF0ZSwgZGVlcCwgZmx1c2ggYW5kIGNsZWFudXA%2Cdescription_~V2hhdCB3YXRjaCBhbmQgd2F0Y2hFZmZlY3QgYWNjZXB0LCB3aGVuIHRoZXkgZmlyZSwgdGhlIGRlZXAgYW5kIGZsdXNoIG9wdGlvbnMsIGNsZWFudXAgZm9yIHN0YWxlIHJlcXVlc3RzLCBhbmQgdGhlIHBhdXNlIGFuZCByZXN1bWUgaGFuZGxlIGZyb20gVnVlIDMuNS4%2Ceyebrow_~RGVlcCBkaXZlIMK3IDYgbWluIHJlYWQ%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy93YXRjaGVycy1pbi1kZXB0aCI%2Cs_9NiXh5ZDD4Gfj9YG.png"
  "twitter:image:alt": "Watchers in depth: immediate, deep, flush and cleanup · vuejs.school"
---

> Index of every page on this site: [llms.txt](https://vuejs.school/llms.txt)

## 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.vue**Breaks***

```
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.vue**Works***

```
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 stuck**3 questions from the Watchers in depth quiz.

[**Practice now**](https://vuejs.school/learn/advanced/watchers-in-depth?tab=quiz)

**Was this helpful?**

## **Keep reading**

[Release notes · 7 min read**Vue 3.6 and Vapor Mode: where things stand**](https://vuejs.school/community/blog/vue-3-6-and-vapor-mode-where-things-stand) [Deep dive · 7 min read**Vapor Mode, explained without the hype**](https://vuejs.school/community/blog/vapor-mode-explained-without-the-hype)

```json
{"@context":"https://schema.org","@graph":[{"@id":"https://vuejs.school/#website","@type":"WebSite","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school/","publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/watchers-in-depth#webpage","@type":"WebPage","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2025-04-15","description":"What watch and watchEffect accept, when they fire, the deep and flush options, cleanup for stale requests, and the pause and resume handle from Vue 3.5.","name":"Watchers in depth: immediate, deep, flush and cleanup","url":"https://vuejs.school/community/blog/watchers-in-depth","breadcrumb":{"@id":"https://vuejs.school/community/blog/watchers-in-depth#breadcrumb"},"isPartOf":{"@id":"https://vuejs.school/#website"},"potentialAction":[{"@type":"ReadAction","target":["https://vuejs.school/community/blog/watchers-in-depth"]}],"primaryImageOfPage":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/#identity","@type":"Organization","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school","logo":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/community/blog/watchers-in-depth#article","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2025-04-15","description":"What watch and watchEffect accept, when they fire, the deep and flush options, cleanup for stale requests, and the pause and resume handle from Vue 3.5.","headline":"Watchers in depth: immediate, deep, flush and cleanup","inLanguage":"en","isAccessibleForFree":true,"thumbnailUrl":"https://vuejs.school/_og/d/a_~V2F0Y2hlcnMgaW4gZGVwdGg6IGltbWVkaWF0ZSwgZGVlcCwgZmx1c2ggYW5kIGNsZWFudXAgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_~V2F0Y2hlcnMgaW4gZGVwdGg6IGltbWVkaWF0ZSwgZGVlcCwgZmx1c2ggYW5kIGNsZWFudXA%2Cdescription_~V2hhdCB3YXRjaCBhbmQgd2F0Y2hFZmZlY3QgYWNjZXB0LCB3aGVuIHRoZXkgZmlyZSwgdGhlIGRlZXAgYW5kIGZsdXNoIG9wdGlvbnMsIGNsZWFudXAgZm9yIHN0YWxlIHJlcXVlc3RzLCBhbmQgdGhlIHBhdXNlIGFuZCByZXN1bWUgaGFuZGxlIGZyb20gVnVlIDMuNS4%2Ceyebrow_~RGVlcCBkaXZlIMK3IDYgbWluIHJlYWQ%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy93YXRjaGVycy1pbi1kZXB0aCI%2Cs_9NiXh5ZDD4Gfj9YG.png","@type":["Article","BlogPosting"],"articleSection":["Deep dive"],"author":{"@id":"https://vuejs.school/#identity"},"image":{"@id":"https://vuejs.school/#/schema/image/1"},"isPartOf":{"@id":"https://vuejs.school/community/blog/watchers-in-depth#webpage"},"mainEntityOfPage":{"@id":"https://vuejs.school/community/blog/watchers-in-depth#webpage"},"publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/watchers-in-depth#breadcrumb","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","name":"Blog","item":"https://vuejs.school/community/blog","position":1},{"@type":"ListItem","name":"Watchers in depth: immediate, deep, flush and cleanup","position":2}]},{"@id":"https://vuejs.school/#logo","@type":"ImageObject","caption":"vuejs.school","contentUrl":"https://vuejs.school/apple-touch-icon.png","inLanguage":"en","url":"https://vuejs.school/apple-touch-icon.png"},{"@id":"https://vuejs.school/#/schema/image/1","@type":"ImageObject","contentUrl":"https://vuejs.school/_og/d/a_~V2F0Y2hlcnMgaW4gZGVwdGg6IGltbWVkaWF0ZSwgZGVlcCwgZmx1c2ggYW5kIGNsZWFudXAgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_~V2F0Y2hlcnMgaW4gZGVwdGg6IGltbWVkaWF0ZSwgZGVlcCwgZmx1c2ggYW5kIGNsZWFudXA%2Cdescription_~V2hhdCB3YXRjaCBhbmQgd2F0Y2hFZmZlY3QgYWNjZXB0LCB3aGVuIHRoZXkgZmlyZSwgdGhlIGRlZXAgYW5kIGZsdXNoIG9wdGlvbnMsIGNsZWFudXAgZm9yIHN0YWxlIHJlcXVlc3RzLCBhbmQgdGhlIHBhdXNlIGFuZCByZXN1bWUgaGFuZGxlIGZyb20gVnVlIDMuNS4%2Ceyebrow_~RGVlcCBkaXZlIMK3IDYgbWluIHJlYWQ%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy93YXRjaGVycy1pbi1kZXB0aCI%2Cs_9NiXh5ZDD4Gfj9YG.png","inLanguage":"en","url":"https://vuejs.school/_og/d/a_~V2F0Y2hlcnMgaW4gZGVwdGg6IGltbWVkaWF0ZSwgZGVlcCwgZmx1c2ggYW5kIGNsZWFudXAgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_~V2F0Y2hlcnMgaW4gZGVwdGg6IGltbWVkaWF0ZSwgZGVlcCwgZmx1c2ggYW5kIGNsZWFudXA%2Cdescription_~V2hhdCB3YXRjaCBhbmQgd2F0Y2hFZmZlY3QgYWNjZXB0LCB3aGVuIHRoZXkgZmlyZSwgdGhlIGRlZXAgYW5kIGZsdXNoIG9wdGlvbnMsIGNsZWFudXAgZm9yIHN0YWxlIHJlcXVlc3RzLCBhbmQgdGhlIHBhdXNlIGFuZCByZXN1bWUgaGFuZGxlIGZyb20gVnVlIDMuNS4%2Ceyebrow_~RGVlcCBkaXZlIMK3IDYgbWluIHJlYWQ%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy93YXRjaGVycy1pbi1kZXB0aCI%2Cs_9NiXh5ZDD4Gfj9YG.png"}]}
```