Writing props. in front of every prop gets tedious, so destructuring defineProps() is tempting. Since Vue 3.5 it is safe in most code: a destructured count read in a computed or a watchEffect updates just as props.count does, and you can give it a default with plain JavaScript. Two cases still go wrong. Pass the prop straight to watch() and the build fails. Pass it into a composable and the build succeeds, but the component keeps showing data for the first value it received. Both have a one-line fix.
Why destructuring used to lose reactivity
defineProps() returns a reactive object. Vue tracks reads on it by property: when a computed or a template reads props.count, Vue notes that it depends on count, and re-runs it when the parent passes a new value.
Destructuring copies values out of an object at the moment it runs. In Vue 3.4 and earlier, this:
const { count } = defineProps<{ count: number }>()
ran once during setup and gave you a plain constant. The template still updated, because the template reads through props under the hood, but anything in the script that used count saw the first value forever. A watchEffect that logged count ran once and never again. That silent staleness is why the Vue docs told you to keep props. in front of everything.
Vue 3.3 shipped an experimental, opt-in fix. Vue 3.5, released in September 2024, declared it stable and turned it on by default (the 3.5 announcement).
What the compiler rewrites
The fix lives in the compiler, not at runtime. When <script setup> destructures defineProps(), the compiler drops the destructure and remembers each name. Wherever one of those names is used in the same script block, it writes a read from the props object instead. In the compiled component that object is called __props. So this:
<script setup lang="ts">
import { computed } from 'vue'
const { amount, currency } = defineProps<{ amount: number, currency: string }>()
const label = computed(() => `${amount.toFixed(2)} ${currency}`)
</script>
compiles as if you had written this:
const __props = defineProps<{ amount: number, currency: string }>()
const label = computed(() => `${__props.amount.toFixed(2)} ${__props.currency}`)
The read of amount now happens inside the computed, on the reactive object, every time the computed runs. Reactivity is intact because the destructure never really happened. The template is unaffected either way: {{ amount }} always read through the props object, which is why templates never needed the props. prefix in the first place.
Two consequences follow from this:
- The rewrite only reaches code in the same
<script setup>block. A function written in another file gets whatever value you pass it, which is the cause of the second gotcha below. - A destructured prop is still a prop, so you cannot assign to it.
amount = 5is a compile error:Cannot assign to destructured props as they are readonly.
If you want to see which identifiers are props while you read, the Vue extension for VS Code has an inlay-hints setting that marks destructured props in the editor (the props guide mentions it).
Declare defaults without withDefaults
Before 3.5, a type-based props declaration needed withDefaults() to give a prop a default value. With destructure, you use JavaScript's own default syntax:
<script setup lang="ts">
const {
page = 1,
perPage = 20,
sizes = [10, 20, 50],
} = defineProps<{ page?: number, perPage?: number, sizes?: number[] }>()
</script>
<template>
<p>Page {{ page }}, {{ perPage }} per page</p>
<select><option v-for="s in sizes" :key="s">{{ s }}</option></select>
</template>
The compiler turns these into the runtime default options you would have written by hand. Look at sizes: with withDefaults you had to write sizes: () => [10, 20, 50], a factory function, so that each component instance got its own array. The compiler now does that wrapping for you whenever the default is not a plain literal, so an array or object default is safe to write directly.
The compiler also checks the default against the declared type. page = '1' with page?: number fails the build with Default value of prop "page" does not match declared type.
withDefaults() still works and is not deprecated. Prefer the destructure form in new code; it is shorter and keeps the default next to the name.
Watch a destructured prop through a getter
The first gotcha looks like a normal watcher, but the build rejects it:
<script setup lang="ts">
import { watch } from 'vue'
const { query } = defineProps<{ query: string }>()
watch(query, (next) => {
console.log('query changed to', next)
})
</script>
Remember the rewrite: watch(query, ...) would become watch(__props.query, ...), which passes a string to watch. A string is a value, not a source, so there would be nothing to watch. The compiler catches this case and stops the build with:
"query" is a destructured prop and should not be passed directly to watch(). Pass a getter () => query instead.
Do what it says. A getter is a function that returns the value; watch calls it, sees the read of __props.query inside, and tracks it:
<script setup lang="ts">
import { watch } from 'vue'
const { query } = defineProps<{ query: string }>()
watch(() => query, (next) => {
console.log('query changed to', next)
})
</script>
This is the same rule as before 3.5, when you wrote watch(() => props.query, ...). watchEffect needs no getter, because the read of query already happens inside the effect function.
The compiler runs the same check on toRef(query), and the message then names toRef(). Those two calls are the only ones it checks, which brings us to the second gotcha.
Pass a getter to composables
Composables (functions that bundle reactive logic so several components can share it) usually take a ref or a getter so they can react to changes. Pass a destructured prop directly and the compiler cannot help you, because it has no idea what your function expects:
<script setup lang="ts">
import { useFetchUser } from '@/composables/useFetchUser'
const { userId } = defineProps<{ userId: number }>()
const { user } = useFetchUser(userId)
</script>
This compiles without complaint, into useFetchUser(__props.userId). If the parent passes 42, the composable receives the number 42, fetches user 42, and never notices when the parent switches to user 43. The component shows stale data and nothing warns you.
Wrap it in a getter, exactly as with watch:
<script setup lang="ts">
import { useFetchUser } from '@/composables/useFetchUser'
const { userId } = defineProps<{ userId: number }>()
const { user } = useFetchUser(() => userId)
</script>
On the receiving side, the composable should accept a ref, a getter or a plain value, and normalise with toValue() from Vue wherever it reads the input:
import { ref, toValue, watchEffect, type MaybeRefOrGetter } from 'vue'
export function useFetchUser(id: MaybeRefOrGetter<number>) {
const user = ref<{ name: string } | null>(null)
watchEffect(async () => {
const response = await fetch(`/api/users/${toValue(id)}`)
user.value = await response.json()
})
return { user }
}
toValue(id) inside watchEffect calls the getter, which reads __props.userId, which the effect tracks. If you would rather hand the composable a ref, toRef(() => userId) makes one from the getter.
The same care applies to provide(). Writing provide('userId', userId) hands the children the current number, and the compiler says nothing. Provide a computed, provide('userId', computed(() => userId)), and the children can follow the prop as it changes.
The rule behind both gotchas
Both problems have the same shape. A destructured prop is props.count in disguise. Reading it where you need the value, in a computed, a watchEffect or the template, is fine: the compiler makes that read reactive. Passing it to a function hands over the current value, not the connection to the prop. Whenever a function needs to see future values, give it () => count.
Turn props destructure off for a project
Most projects should leave the feature on: the code reads better, and the two gotchas are easy to spot once you know the rewrite. If your team prefers the explicit props.count style, switch it off in the Vue plugin's options:
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue({ features: { propsDestructure: false } })],
})
The features option needs @vitejs/plugin-vue 5.1 or later; it replaces the deprecated script: { propsDestructure: false }.
false does not stop anyone destructuring. A destructured prop then compiles to a plain constant, with no warning. In 3.5 the template reads that constant too: unlike in 3.4, {{ amount }} also keeps showing the first value.
To forbid destructuring outright, the compiler also accepts 'error', which fails the build on any props destructure with Props destructure is explicitly prohibited via config. The plugin passes that value on, but its 5.2 types allow only true or false, so TypeScript flags it in vite.config.ts.