skip to content

In Vue 3.5 `<script setup>`, why does a prop destructured from `defineProps()` stay reactive inside `computed`, yet `watch(id, cb)` on it fails?

level: middleimportance: should knowfreq 52%

answer

  1. 3.5 compiler rewrite
  2. id becomes __props.id
  3. a value is not a watch source
  4. wrap it in a getter

basics

~20 s

Since Vue 3.5 the SFC compiler rewrites reads of a destructured prop into props.id, so reads inside computed or watchEffect are tracked. watch(id) would receive a value, not a source, so the compiler rejects it; write watch(() => id, cb).

solid answer

~50 s

In Vue 3.5, `const { id } = defineProps<{ id: number }>()` does not create a constant. The SFC compiler records `id` as a destructured prop and rewrites each later access in that `<script setup>` block to `__props.id`, so `computed(() => id * 2)` really reads `__props.id` inside the getter and is tracked. The rewrite cannot help where the value is passed rather than read in a tracked function: `watch(id, cb)` would become `watch(__props.id, cb)`, handing `watch` a number. The compiler catches that case (and `toRef(id)`) with an error telling you to pass a getter, `watch(() => id, cb)`. It does **not** catch other calls: `useThing(id)` silently passes the current value, so pass `() => id` there too. Destructuring also gives native default values (`const { id = 0 } = ...`); before 3.5 this destructure was not enabled by default and produced plain constants.

code

vue · 19 lines
vue
<script setup lang="ts">
import { computed, watch } from 'vue'
import { useUserPreview } from './useUserPreview'

const { id, label = 'Untitled' } = defineProps<{ id: number; label?: string }>()

const title = computed(() => `${label} #${id}`) // tracked: reads __props inside the getter

// watch(id, ...)             -> compile error: pass a getter instead
watch(() => id, (next) => console.log('id changed to', next))

// useUserPreview(id)         -> compiles, but passes a number snapshot
const preview = useUserPreview(() => id) // stays in sync with the prop
</script>

<template>
  <h2>{{ title }}</h2>
  <p>{{ preview }}</p>
</template>

go deeper

for a junior

Recall that in Vue 3.5 destructured props stay reactive in computeds and templates, and that watch needs a getter such as () => id.

for a middle

Explain the compile-time rewrite to a props access, why reads must happen inside tracked functions, and which calls the compiler guards.

for a senior

Catch the silent case - destructured props passed to composables as snapshots - and choose between destructure and props.x as a team style.

for a principal

Weigh the readability of destructured props against the review risk of values that look local but are reactive, and set a codebase convention.

## What changed in Vue 3.5 **Reactive Props Destructure** became the default in Vue 3.5. The docs describe it plainly: in 3.5 and above, variables destructured from `defineProps()` are reactive, because the compiler prepends `props.` whenever code in the same `<script setup>` block accesses them. In 3.4 and below the feature was not enabled by default, so a destructured prop was an ordinary constant that never changed. ```ts const { id, label = 'Untitled' } = defineProps<{ id: number; label?: string }>() const doubled = computed(() => id * 2) // compiled roughly as: computed(() => __props.id * 2) ``` Two benefits come with it: - **Reactive reads without the `props.` prefix** in computeds, `watchEffect`, templates and handlers. - **Native default values**: `label = 'Untitled'` replaces `withDefaults()` for type-based declarations. ## Why reads work: they happen inside tracked functions The rewrite turns `id` into a property access on the props object, which is reactive. Whether that read is **tracked** still depends on where it runs: | Code | After the rewrite | Tracked? | |---|---|---| | `computed(() => id * 2)` | reads `__props.id` inside the getter | yes | | `watchEffect(() => log(id))` | reads `__props.id` inside the effect | yes | | `const copy = id` at the top of setup | reads `__props.id` once, during setup | no, `copy` is a snapshot | | `watch(id, cb)` | passes the value of `__props.id` | not a valid source | | `useThing(id)` | passes the value of `__props.id` | no, the composable gets a number | The rewrite changes *what is read*, not *when*. A read that happens once, at setup time, still captures a single value. ## The compiler's guard for `watch` and `toRef` The SFC compiler checks calls to Vue's `watch()` and `toRef()` and stops with an error when the first argument is a destructured prop: ```text "id" is a destructured prop and should not be passed directly to watch(). Pass a getter () => id instead. ``` The fix is exactly what it says: ```ts watch(() => id, (next, prev) => { /* ... */ }) const idRef = toRef(() => id) // readonly ref over the prop ``` ## Passing a destructured prop to your own functions The compiler checks only `watch()` and `toRef()`. For any other function - a composable, a helper - it quietly passes the current value. The docs recommend passing a getter, `useThing(() => id)`, and having the function read it with `toValue()` inside a computed or watcher getter so the read is tracked. ## Other rules the destructure brings - **No assignment.** Writing `id = 2` is a compile error: destructured props are readonly, like `props.id`. - **Same block only.** The rewrite applies inside the `<script setup>` block that called `defineProps()`. A value you export or hand to another module is just a value. - **Rest element.** `const { id, ...rest } = defineProps()` makes `rest` an object whose getters read the remaining props, so `rest.label` read inside a computed is tracked. ## When to keep `props.id` Some teams keep `const props = defineProps()` and write `props.id` everywhere, because the prefix makes it obvious at each use that the value is a reactive prop and needs a getter when passed on. Both styles compile to the same access; the choice is about readability and review safety, not behaviour. ## Upgrading code written for 3.4 1. **Behaviour can change silently.** A `watchEffect` that read a destructured prop ran once in 3.4 (the prop was a constant) and now re-runs when the prop changes. Code that relied on the old snapshot, knowingly or not, should copy the value explicitly with a comment. 2. **`withDefaults()` becomes optional.** Native default values in the destructure replace it for type-based props. 3. **Build errors surface old bugs.** Any `watch(prop, ...)` on a destructured prop now fails to compile - in 3.4 it compiled and silently watched a constant.

  • In Vue 3.5, does `const copy = id` at the top of `<script setup>` stay in sync with a destructured prop `id`?
    No. The compiler rewrites it to `const copy = __props.id`, which runs once during setup and stores the current value. Only reads that happen inside a tracked function - a computed getter, `watchEffect`, a watch getter or the template - follow later changes.
  • Why does the Vue compiler catch `watch(id)` but not `useThing(id)`?
    It checks only calls to Vue's own `watch()` and `toRef()`, whose first argument must be a reactive source. It cannot know whether an arbitrary function expects a value or a source, so passing a destructured prop to your own function compiles and silently hands it a snapshot.

saying these in an interview costs you the question

  • In Vue 3.5, a destructured prop is a constant that never updates.
  • watch(id, cb) works because the compiler turns id into a reactive source.
  • The compiler also warns when a destructured prop is passed to any composable.
  • You can assign to a destructured prop to change it locally.
  • Props destructure has been enabled by default since Vue 3.0.