跪拜 Guibai
← Back to the summary

Destructuring Props in Vue 3 <script setup> Silently Kills Reactivity

Problem Scenario

The project uses Vue 3 + TypeScript + <script setup>, and there is a user card component:

<script setup lang="ts">
const props = defineProps<{
  user: { name: string; age: number }
  theme: 'light' | 'dark'
}>()

const { user, theme } = props
</script>

<template>
  <div :class="theme">
    <p>{{ user.name }} - {{ user.age }}岁</p>
  </div>
</template>

Looks normal, right? Destructure and use directly, clean and elegant.

However, the production behavior is: after the parent component updates user.name, the page always shows the old value. The new data only appears after switching routes or triggering a forced refresh.

Cause Analysis

Vue 3's defineProps returns a reactive proxy object. The following two approaches are fundamentally different:

// ✅ Access via props.xxx — preserves the reactivity chain
const name = computed(() => props.user.name)

// ❌ const { user } = props — breaks the reactivity chain
const { user } = props
// user is a plain JS object, no longer referencing props.user

Destructuring assignment performs a value copy at compile time — copying the value for primitive types (theme) and copying the reference address for reference types (user).

It sounds like reference types should be fine? The problem lies in Vue 3's Proxy layer: props.user returns a proxied object, but the destructured user gets a snapshot at the moment of destructuring. Vue's dependency tracking only tracks access to the props.user key, not the destructured local variable.

The Deeper the Nesting, the More Hidden

const { user } = props
// Later: user.name = 'new name'
// Vue's set interceptor operates on the user object itself, not props.user
// The parent component modifies via props.user.name = 'new name',
// but the child component's user variable still holds the old reference (for primitive types, they no longer share memory)

The core takeaway: destructuring bypasses the reactive proxy; you get a one-time copy of the raw data.

Solutions

Solution 1: Don't Destructure, Use props.xxx Directly

<script setup lang="ts">
const props = defineProps<{
  user: { name: string; age: number }
  theme: 'light' | 'dark'
}>()
</script>

<template>
  <div :class="props.theme">
    <p>{{ props.user.name }} - {{ props.user.age }}岁</p>
  </div>
</template>

Writing props. in the template feels verbose? In Vue 3 templates, you don't need to explicitly write props. to access props; just use {{ user.name }} directly — the template compiler automatically resolves it to props.user.name.

Solution 2: computed for Writable Reactivity

When the JS logic layer needs to manipulate props data:

const localUser = computed(() => props.user)
// ✅ localUser.value always follows props.user changes

Solution 3: toRefs to Preserve Per-Field Reactivity

const { user, theme } = toRefs(props)
// ✅ user and theme are both Refs, preserving reactivity
// In the template, use user.name + user.age (no user.value needed, template auto-unwraps)

⚠️ Special Note: defineProps TS-Only Syntax

// This is a pure type declaration with no runtime validation
defineProps<{ ... }>()

// Using withDefaults for default values does not affect reactivity characteristics
withDefaults(defineProps<{ count?: number }>(), {
  count: 0
})
// Destructuring still loses reactivity!

Key Summary

Approach Reactive Recommended
Template directly {{ user.name }} ✅ Preferred for production
const user = props.user then used in template ❌ Do not destructure
toRefs(props) ✅ When reactivity must be preserved
computed(() => props.user) ✅ When there is computation logic
Deep destructuring const { name } = props.user ❌ Double break

Golden Rule: Inside <script setup>, never destructure defineProps. Template variables are automatically read from props, and the JS logic layer wraps them with computed or toRefs.

Comments

Top 2 of 3 from juejin.cn, machine-translated. The original thread is authoritative.

向新出发叭 1 likes

I've stepped in this pitfall too, and more than once — almost every time a new colleague joins, it happens all over again. You've nailed the core reason: defineProps returns a reactive proxy object, and destructuring assignment is essentially a value copy, which directly breaks the Proxy's interception chain. The most intuitive way to understand it is: what you get from destructuring is a 'snapshot of that moment,' and when the parent component updates the props later, the child component simply can't perceive it. To add a scenario that's easily confused in real projects: some people think only primitive types (string, number) lose reactivity when destructured, while reference types (object, array) don't. Actually, even though destructuring a reference type gives you the same reference, Vue's dependency collection is completed inside the proxy's get trap. Destructuring bypasses the proxy, so deep property changes on reference types can also fail to be tracked. This issue is especially stealthy during debugging because sometimes it looks 'usable,' but the dependency collection is actually incomplete. My current habit aligns with what you suggested, basically three paths: use props.xxx directly in the template; wrap it with computed when you need it in JS logic; if you really want to destructure, use toRefs — it's one extra line of code but gives peace of mind. Also, a small detail about toRefs: every property it returns is a ref, so you need to access it with .value in JS, but it auto-unwraps in the template. When I first started using it, I often forgot .value and got undefined, but I got used to it later. Great article, explains the problem thoroughly — beginners should avoid a lot of detours after reading it.

前端阿凡

[thanks][thanks][thanks][thanks]

golyu

Just do const { user, theme } = defineProps<{ user: { name: string; age: number } theme: 'light' | 'dark' }>()