How to Use the watch Function in Vue 3 Setup

In Vue 3, the watch function imported from vue creates a reactive effect inside setup() that executes a callback whenever the tracked source changes, receiving the new value, old value, and an optional cleanup function.

The watch function is a fundamental reactivity API in the Vue 3 Composition API, providing a declarative way to perform side effects in response to state changes. According to the vuejs/core source code, the implementation lives in packages/runtime-core/src/watch.ts and builds on top of Vue's proxy-based reactivity system to handle dependency tracking and scheduling.

Core Implementation and Source Normalization in vuejs/core

The watch function is implemented in packages/runtime-core/src/watch.ts and re-exported via packages/runtime-core/src/apiWatch.ts as part of the public Composition API surface. When you invoke watch inside a component's setup(), the runtime performs source normalization to convert your input—whether a getter function, a ref, a reactive object, or an array of these—into a unified sourceGetter.

This normalization allows the core scheduler in packages/runtime-core/src/component.ts to treat every reactive dependency uniformly. The watcher collects accessed properties into a Dep set, and when any dependency changes, the scheduler queues your callback to run on the next micro-task (or immediately if flush: 'sync' is specified).

Basic Usage with Refs

The simplest use case watches a single ref and receives both the new and previous values in the callback.

import { ref, watch } from 'vue'

export default {
  setup() {
    const count = ref(0)

    watch(count, (newVal, oldVal) => {
      console.log(`count changed from ${oldVal} → ${newVal}`)
    })

    return { count }
  }
}

In this pattern, watch automatically unwraps the ref and begins tracking its value changes through Vue's reactivity proxy.

Deep Observation of Reactive Objects

When watching a reactive object or nested properties within one, you must use a getter function and enable the deep option to force recursive traversal. According to the implementation in packages/runtime-core/src/watch.ts, the deep flag triggers a traverse of the source to register nested properties as dependencies.

import { reactive, watch } from 'vue'

export default {
  setup() {
    const state = reactive({ user: { name: 'Alice' } })

    watch(
      () => state.user,
      (newUser, oldUser) => {
        console.log('User object changed', newUser)
      },
      { deep: true }
    )

    return { state }
  }
}

Without the getter function () => state.user, the watcher cannot properly establish reactive tracking on the specific nested object.

Tracking Multiple Sources Simultaneously

You can observe several reactive sources at once by passing an array to watch. The callback receives arrays of new and old values corresponding to the order of the sources.

import { ref, watch } from 'vue'

export default {
  setup() {
    const firstName = ref('John')
    const lastName = ref('Doe')

    watch([firstName, lastName], ([newFirst, newLast], [oldFirst, oldLast]) => {
      console.log(`Full name changed to ${newFirst} ${newLast}`)
    })

    return { firstName, lastName }
  }
}

This approach leverages the same normalization logic in packages/runtime-core/src/watch.ts to create a composite getter that tracks all array members.

Immediate Execution and Cleanup Patterns

Immediate Invocation on Setup

Use the immediate option to execute the callback immediately after the watcher is created, before any state mutations occur. This is useful for initializing data based on the current reactive state.

import { ref, watch } from 'vue'

export default {
  setup() {
    const query = ref('')

    watch(
      query,
      (q) => {
        console.log('Search for:', q)
      },
      { immediate: true }
    )

    return { query }
  }
}

Cleanup Functions for Async Operations

The watch callback receives a third argument, onCleanup, which allows you to register cleanup logic that runs before the callback re-executes. This is essential for aborting previous asynchronous work, such as pending API calls when a search term changes rapidly.

import { ref, watch } from 'vue'

export default {
  setup() {
    const term = ref('')

    watch(
      term,
      (newTerm, _, onCleanup) => {
        const controller = new AbortController()
        fetch(`/search?q=${newTerm}`, { signal: controller.signal })
          .then(r => r.json())
          .then(data => console.log(data))

        onCleanup(() => controller.abort())
      },
      { immediate: true }
    )

    return { term }
  }
}

The scheduler in packages/runtime-core/src/watch.ts ensures that onCleanup is invoked before running the next iteration of your callback, preventing race conditions in side effects.

Summary

  • The watch function in Vue 3's setup() is implemented in packages/runtime-core/src/watch.ts and integrates with the core reactivity system defined in packages/runtime-core/src/reactivity.ts.
  • Source normalization converts refs, reactive objects, or arrays into unified getters for uniform dependency tracking.
  • The callback receives newValue, oldValue, and an onCleanup handler for managing async side effects between executions.
  • Use { deep: true } when watching nested properties inside reactive objects, and { immediate: true } to trigger the callback during initialization.
  • Multiple sources can be watched using array syntax, with corresponding value arrays returned to the callback.

Frequently Asked Questions

What is the difference between watch and watchEffect in Vue 3?

watch requires you to explicitly specify the reactive source to track, whereas watchEffect automatically tracks all reactive dependencies accessed during its execution. According to the vuejs/core implementation, watch is a thin wrapper around lower-level utilities that provides access to the previous value and supports the deep option, while watchEffect is more suitable for cases where you do not need to know which specific dependency triggered the update or access the previous state.

Why do I need to use a getter function when watching reactive objects?

Vue's reactivity system requires an explicit getter to establish a dependency on a specific property of a reactive object. When you pass a getter like () => state.user to watch, the function executes during dependency collection in packages/runtime-core/src/reactivity.ts, allowing Vue's proxies to track that specific path. Passing the raw reactive object would not provide the granular tracking needed to detect changes to nested properties unless you also enable deep: true.

When should I use the flush option?

By default, Vue queues watch callbacks to run after the current synchronous execution cycle completes (micro-task timing). Set flush: 'sync' to force the callback to run immediately when the dependency changes, which is useful when you need to observe changes before the DOM updates or to maintain strict timing consistency with external libraries.

How do I clean up side effects when a watcher re-runs or the component unmounts?

Use the onCleanup function passed as the third argument to your watch callback to register cleanup logic for async operations like pending API calls. Additionally, as implemented in packages/runtime-core/src/component.ts, Vue automatically manages watcher teardown when the component instance is destroyed, ensuring that reactive effects registered during setup() do not leak after the component unmounts.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →