In Vue 3, which source forms does watch() accept, and how does each form decide whether the callback runs?
answer
- four shapes for the first argument
- reactive object means implicit deep
- getter compares its return value
- array fires when any entry changes
- a plain value earns a warning
basics
~20 sVue 3's watch() accepts a ref, a reactive object, a getter function, or an array of these. A ref or getter fires when its value changes by Object.is; a reactive object is watched deeply; an array fires when any entry changes.
solid answer
~40 s`watch()` takes four source forms. A **ref**, including a computed ref, fires when `.value` is replaced by a value that differs by `Object.is`. A **reactive object** passed directly is implicitly deep, so any nested mutation fires the callback even though the object stays the same. A **getter** such as `() => state.count` is tracked on whatever it reads, and the callback fires only when its return value changes. An **array** of those fires when any one entry changes and passes arrays of new and old values. Anything else, such as a plain number, logs an `Invalid watch source` warning in development and never fires.
code
ts · 21 linesimport { ref, reactive, watch } from 'vue'
const page = ref(1)
const state = reactive({ user: { name: 'Ada' }, count: 0 })
// ref: fires when page.value is replaced
watch(page, (p, prev) => console.log(p, prev))
// reactive object: implicitly deep, fires on state.user.name = 'Bob'
watch(state, () => console.log('something inside state changed'))
// getter: fires only when the returned number changes
watch(() => state.count, (c, prev) => console.log(c, prev))
// array: fires when either entry changes
watch([page, () => state.count], ([p, c], [prevP, prevC]) => {
console.log(p, c, prevP, prevC)
})
// invalid: passes a number, logs 'Invalid watch source' in dev
// watch(state.count, () => {})go deeper
Recall the four source forms: a ref, a reactive object, a getter function and an array of them. Be ready to say why watch(state.count, cb) is wrong.
Explain how each form detects change: Object.is on a ref value or getter result, implicit deep traversal for a reactive object, any-entry semantics for arrays.
Show judgment about cost and precision: prefer narrow getters over whole reactive objects, and spot refs holding arrays that are mutated in place but never fire.
Frame source choice as a team convention: getters for props and store fields, deep sources only with a stated reason, and a lint or review rule for bare expressions.
## What a watch source is In Vue 3, `watch(source, callback, options?)` separates two things: the **source**, which tells Vue what to track, and the **callback**, which runs when the tracked value changes. Vue turns every source into an internal getter, runs it inside a reactive effect so that every reactive read during the run is recorded as a dependency, and re-runs it when one of those dependencies is written. After each re-run it decides whether the callback should fire. The source form therefore controls two things at once: **what gets tracked** and **how change is detected**. The first argument accepts exactly four shapes: - a **ref**, including a computed ref, a `shallowRef` and a ref returned by `toRef`; - a **reactive object** created with `reactive()` (a `shallowReactive()` object is traversed at its root level only); - a **getter function** such as `() => state.count` or `() => props.id`; - an **array** mixing any of the above. Anything else, such as a number, a string or a plain non-reactive object, is not a source. In development Vue logs `Invalid watch source` with the offending value and explains that a source can only be a getter/effect function, a ref, a reactive object, or an array of these types. The watcher then has no dependencies and its callback never runs. ## How each form detects change | Source form | What the internal getter reads | When the callback fires (no options) | |---|---|---| | `ref` | `source.value` | `.value` is replaced by a value that differs by `Object.is` | | `shallowRef` | `source.value` | whenever the ref is triggered, including by `triggerRef()` | | reactive object | every nested property (implicit deep traversal) | on any nested mutation, although the object is the same | | getter | whatever the function reads | the returned value differs from the previous one by `Object.is` | | array | each entry, by the rules above | any entry changes; the callback gets arrays of new and old values | Two consequences are worth stating plainly: 1. A **getter is compared by its return value**, not by what it read. `() => state.user` re-runs when `state.user` is reassigned, but a nested edit such as `state.user.name = 'B'` is not even tracked, because the getter never read `name`. 2. A **reactive object passed directly is implicitly deep**. Vue traverses its nested properties to track them and fires on any mutation without comparing values, because the object identity never changes. ## Refs that hold objects A ref holding an object or array is a common trap. `const list = ref([])` stores a reactive array in `list.value`, but `watch(list, cb)` only reads `list.value`. Calling `list.value.push(item)` mutates the array in place; `.value` still points at the same array, so the ref's own value was never written and the callback does not run. Either reassign (`list.value = [...list.value, item]`), watch a getter over what matters (`() => list.value.length`), or add `{ deep: true }`. `shallowRef` differs in one respect: Vue treats a triggered shallow ref source as changed, so calling `triggerRef()` after an in-place mutation does fire the callback. ## Array sources An array source watches several independent values with one callback: - the callback receives `(newValues, oldValues)`, both arrays in source order; - it fires when **at least one** entry changes, not only when all of them do; - entries follow their own rules, so a reactive object inside the array is still watched deeply; - on an `immediate` first call the old-values argument is an empty array, so each destructured old value is `undefined`. ## Choosing a form A practical order of preference in `<script setup>` code: 1. Watch a **ref** directly when the ref is the state. 2. Use a **getter** for a property of a reactive object or of `props`, or for a derived primitive, because it tracks exactly what it reads and compares cheaply. 3. Use an **array** when one side effect depends on several such values. 4. Pass a **reactive object** only when you really want every nested mutation; deep traversal walks the whole structure on each run and gets expensive on large data. The common thread is that `watch()` needs something it can re-evaluate. A bare expression like `state.count` is evaluated once, before `watch()` is even called, and hands Vue a number with no link back to the state it came from.
- What happens if you watch a ref whose value is an array and push into it?Nothing, by default. The watcher only reads `.value`, so it depends on the ref being reassigned, not on the array's contents. `push` mutates the reactive array in place and `.value` keeps the same identity. Fix it with `{ deep: true }`, by reassigning a new array, or by watching a getter such as `() => list.value.length` if only the size matters.
- With an array source and `immediate: true`, what old values does the first call receive?On the eager first call there is no previous run, so Vue passes an empty array as the old values; destructuring it as `[prevA, prevB]` gives `undefined` for each. A single source receives `undefined` directly. After the first real change, the old-values array holds the previous value of every entry in source order.
saying these in an interview costs you the question
- watch() can take any expression, like state.count, and track it
- A getter source is watched deeply by default
- Watching a ref that holds an array fires when you push into it
- A reactive object source only fires when the object is replaced
- An array source fires only when every entry has changed