How to Use Vue Watch to Detect Changes in Nested Data Objects

Enable the deep option in Vue's watch API to recursively track mutations at any level of a nested object, or provide a numeric depth value to limit the traversal scope.

When working with complex reactive state in Vue 3, you often need to monitor changes occurring deep within object hierarchies. The watch API in the vuejs/core repository provides a robust mechanism for detecting nested data mutations through its deep traversal system implemented in the reactivity package.

Understanding the deep Option in Vue Watch

The watch function accepts an options object where deep can be either a boolean or a number. According to the WatchOptions interface in the source code, this flag controls how thoroughly Vue walks through the reactive graph.

When deep is enabled, Vue wraps your original getter function with a traversal mechanism. In packages/reactivity/src/watch.ts, lines 36-44 handle this adaptation:

if (deep) {
  const baseGetter = getter
  const depth = deep === true ? Infinity : deep
  getter = () => traverse(baseGetter(), depth)
}

This replacement ensures that every time the watcher evaluates its source, it traverses the entire object tree (or to the specified depth), registering dependencies on every nested property encountered.

How the traverse Function Detects Nested Changes

The core traversal logic resides in the traverse function within packages/reactivity/src/watch.ts (lines 31-66). This utility recursively visits refs, arrays, plain objects, Maps, and Sets while respecting depth limits to prevent infinite recursion.

The function signature and key logic look like this:

export function traverse(value, depth = Infinity, seen?) {
  // Stop when depth exhausted or non-object
  if (depth <= 0 || !isObject(value) || (value as any)[ReactiveFlags.SKIP]) {
    return value
  }
  // …handles Ref, Array, Set/Map, PlainObject, Symbol properties…
}

When traverse visits a property, it triggers the getter of that reactive property, which automatically registers the watcher as a dependency. Consequently, any future mutation to that property—no matter how deeply nested—will notify the watcher and trigger your callback.

Practical Examples for Detecting Nested Changes

Deep Watch on Reactive Objects

The most common use case involves watching a reactive object with arbitrary nesting:

import { reactive, watch } from 'vue'

const state = reactive({
  user: {
    name: 'Alice',
    address: { city: 'Paris', zip: '75000' }
  }
})

// Enable deep watching to catch any nested mutation
watch(
  () => state,
  (newVal, oldVal) => {
    console.log('Nested change detected:', newVal)
  },
  { deep: true }
)

// These mutations will trigger the watcher
state.user.address.city = 'Lyon'
state.user.name = 'Bob'

Limited Depth Watching

For performance optimization, you can specify exactly how many levels deep Vue should traverse:

watch(
  () => state,
  (newVal) => console.log('Change detected up to depth 2'),
  { deep: 2 }  // Only traverse 2 levels deep
)

This corresponds to passing depth === 2 to the internal traverse function, preventing unnecessary dependency tracking on deeply buried properties that you don't need to monitor.

Watching Refs with Nested Data

When using ref instead of reactive, you still need deep: true to detect mutations inside the object:

import { ref, watch } from 'vue'

const data = ref({
  items: [{ id: 1 }, { id: 2 }]
})

watch(data, (newVal) => console.log('Ref updated'), { deep: true })

// This mutation triggers the watcher
data.value.items[0].id = 42

Performance Considerations for Deep Watching

While deep: true provides comprehensive change detection, it comes with computational overhead. The traverse function must walk every property in your object tree during each evaluation, which can become expensive with large datasets.

Consider these optimization strategies:

  • Use specific getters when possible: Instead of watching an entire object with deep: true, watch specific nested properties using computed getters like () => state.user.profile.
  • Limit traversal depth: Use a numeric deep value to restrict how far Vue walks into your data structure.
  • Avoid watching massive collections: Deep watching a Map with thousands of entries or a massive array will trigger traversal of every element.

Summary

  • Enable deep watching in Vue by setting { deep: true } in your watch options to detect mutations at any nesting level.
  • The internal traverse function in packages/reactivity/src/watch.ts recursively walks through refs, arrays, objects, Maps, and Sets to register dependencies.
  • Use a numeric depth value (e.g., { deep: 2 }) to limit traversal scope and improve performance on large objects.
  • For specific nested properties, consider using a computed getter instead of deep watching the entire object to minimize reactivity overhead.

Frequently Asked Questions

Does Vue watch detect nested changes by default?

No, Vue's watch does not detect nested changes by default. Without the deep option, the watcher only triggers when the watched reference itself changes (i.e., when you assign a new object to the variable). To detect property mutations inside an object, you must explicitly set { deep: true } or provide a numeric depth limit.

What is the difference between deep: true and deep: number?

Setting deep: true instructs Vue to traverse the entire object tree with infinite depth (internally represented as Infinity), ensuring every nested property is tracked. Providing a number like deep: 2 limits the traversal to exactly that many levels deep, which improves performance by avoiding dependency tracking on deeply buried properties that you don't need to monitor. Both approaches use the same internal traverse function in packages/reactivity/src/watch.ts.

Can I watch specific nested properties without using deep?

Yes, you can watch specific nested properties without enabling deep watching by using a getter function that returns exactly the value you want to observe. For example, watch(() => state.user.profile.name, (newName) => ...) will trigger only when that specific string changes, without needing to traverse the entire state object. This approach is significantly more performant than deep watching large objects.

Where is the traverse function implemented in Vue?

The traverse function is implemented in packages/reactivity/src/watch.ts within the vuejs/core repository. This utility function handles the recursive walking of reactive data structures including refs, arrays, plain objects, Maps, and Sets. It accepts parameters for the value to traverse, a depth limit, and an optional seen Set to handle circular references, returning the original value after registering all encountered properties as dependencies.

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 →