In a Vue 3 `<script setup>` component, why does `watch(props.userId, cb)` never fire, and how should you watch the prop?
answer
- arguments evaluate before the call
- a number is not a source
- move the read inside a function
- props is shallow reactive
basics
~20 sprops.userId is read once and passes a plain value, not a reactive source, so Vue warns and tracks nothing. Pass a getter, () => props.userId, so the read happens inside the watcher where Vue can track it.
solid answer
~40 sThe argument is evaluated before `watch()` runs, so `watch(props.userId, cb)` hands Vue a string or number. That is not a ref, a reactive object, a function or an array, so in development Vue logs an `Invalid watch source` warning and the watcher has no dependencies. `props` itself is a **shallow reactive** object; a dependency is recorded only when `props.userId` is *read inside* an effect. Writing `watch(() => props.userId, cb)` moves the read into the watcher's getter, so Vue re-runs it when the prop updates and calls the callback when the returned value changes. `toRef(props, 'userId')` is an equivalent ref source, and `watch(props, cb)` works too but reacts to any top-level prop.
code
vue · 20 lines<script setup lang="ts">
import { ref, watch } from 'vue'
const props = defineProps<{ userId: number }>()
const user = ref<{ name: string } | null>(null)
async function loadUser(id: number) {
user.value = await fetch(`/api/users/${id}`).then((r) => r.json())
}
// Wrong: passes a number, logs 'Invalid watch source', never fires
// watch(props.userId, loadUser)
// Right: the getter reads the prop inside the watcher
watch(() => props.userId, (id) => loadUser(id), { immediate: true })
</script>
<template>
<p>{{ user?.name ?? 'Loading...' }}</p>
</template>go deeper
Remember the rule: watch a prop with a getter, () => props.userId. Passing props.userId passes a plain value that Vue cannot track.
Explain the mechanism: arguments evaluate first, props is shallow reactive, and tracking only happens for reads inside the watcher's getter, compared with Object.is.
Point out the object-prop trap: watching props.filters directly binds to one object and silently stops working when the parent replaces it.
Treat prop watching as an API-design signal: many prop watchers that copy into local state suggest a computed or a v-model contract would fit better.
## The bug in one line In a Vue 3 `<script setup>` component, `defineProps()` returns a `props` object, and a very common first attempt at reacting to a prop is: ```ts watch(props.userId, (id) => loadUser(id)) ``` JavaScript evaluates arguments before calling the function. `props.userId` is read once, right there in `setup`, and its current value, say the number `42`, is what `watch()` receives. A number is not a ref, not a reactive object, not a function and not an array, so it is not a valid watch source. In development Vue logs an `Invalid watch source` warning; in any build the watcher has no dependencies and the callback never runs, however often the parent changes the prop. ## Why a getter fixes it Vue's reactivity is based on **tracked reads**: a dependency is recorded when a reactive property is read *while an effect is running*. `props` is a **shallow reactive** object, so reading `props.userId` is trackable, but only if the read happens inside the watcher's effect. ```ts watch(() => props.userId, (id, prevId) => loadUser(id)) ``` Now the read lives inside a function that Vue calls itself: 1. On creation, Vue runs the getter inside the watcher's effect; the read of `props.userId` is recorded. 2. When the parent passes a new value, Vue updates the `props` object, which triggers the effect. 3. Vue re-runs the getter, compares the returned value with the previous one using `Object.is`, and calls the callback if it differs, passing the new and old ids. This is the same rule that makes `watch(() => obj.count, cb)` work for any property of a `reactive()` object: pass a function that performs the read, not the result of the read. ## The valid ways to watch props | Source | Fires when | Notes | |---|---|---| | `() => props.userId` | that prop's value changes | the idiomatic form; compared by `Object.is` | | `toRef(props, 'userId')` | that prop's value changes | a ref linked to the prop; handy to pass into a composable | | `[() => props.a, () => props.b]` | either prop changes | the callback receives arrays of values | | `props` | any top-level prop changes | `props` is shallow reactive, so only root-level properties are tracked | | `ref(props.userId)` | never, for prop changes | copies the current value into an unrelated ref | The last row is the same bug in disguise: `ref(props.userId)` snapshots the value once, and the new ref has no link to the prop. ## When the prop is an object Object props make the bug harder to spot, because `watch(props.filters, cb)` sometimes appears to work: - If the parent passed a **reactive object**, `props.filters` is that proxy, and Vue deep-watches it, so nested edits the parent makes fire the callback. - But the watcher is bound to **that one object**. When the parent later passes a *different* object, the watcher keeps observing the old one and misses every future change. - If the parent passed a **plain object**, for example a literal built in its template, `props.filters` is not reactive at all and the source is invalid. The robust form is again a getter, `() => props.filters`, which re-reads the prop on each run. It fires when the parent replaces the object; add `{ deep: true }` only if you also need nested mutations. ## What to remember - Arguments are evaluated before `watch()` runs, so passing `props.x` passes a value. - A getter defers the read into the watcher, where Vue can track it. - `props` is shallow reactive: watching it directly reacts to top-level prop changes only. - Destructured props follow their own rules; the getter pattern shown here is the baseline every variant builds on.
- If the prop is an object, why is `watch(props.filters, cb)` still a bug even when it seems to fire?If the parent passed a reactive object, Vue deep-watches that one proxy, so nested edits fire. But when the parent passes a new object, the watcher keeps watching the old one and never sees the new value. If the parent passed a plain object, the source is invalid. `() => props.filters` re-reads the prop each run and catches replacements; add `deep: true` for nested edits.
- When would you use `toRef(props, 'userId')` instead of a getter?When the value has to travel: a composable that accepts a ref can receive `toRef(props, 'userId')` and watch or read it later while staying linked to the prop. Inside the component itself, `() => props.userId` is shorter and equivalent as a watch source.
saying these in an interview costs you the question
- props values are refs, so watch(props.userId, cb) tracks the prop
- Wrapping the value with ref(props.userId) keeps it in sync with the parent
- watch(props, cb) reacts to nested mutations inside object props
- The callback does fire, just only after the first render
- A getter source makes Vue deep-watch the whole props object