| |

Vue.js 29 🟢 Reactive Props Destructure with Native Default Values Syntax

In Vue 3.4 and earlier, destructuring props was a known footgun. The official documentation warned against it, and the ESLint plugin had a rule to catch it. The reason was simple: defineProps() returns a reactive object, and destructuring it reads the current value and stores it in a plain variable. The variable is no longer connected to the reactive source, so it never updates when the prop changes. The recommended pattern was to access props through the props object (props.count) or to wrap them with toRef() or toRefs().

Vue 3.5 changed this. Reactive Props Destructure was stabilized and enabled by default . The compiler now transforms destructured prop variables so that every access is compiled into a props.xxx read. When you write const { count } = defineProps(...), the compiler rewrites every reference to count in your code into props.count. This means the variable is no longer a disconnected copy; it is a compiled alias for the reactive property . The destructured variable is reactive on access.

The most immediate benefit is the elimination of the withDefaults() macro for simple cases. TypeScript interfaces cannot contain runtime default values, so Vue 3.4 introduced withDefaults as a separate macro to supply them. With Reactive Props Destructure, you can use JavaScript’s native default value syntax directly in the destructuring pattern: const { count = 0 } = defineProps<{ count?: number }>(). The compiler generates the runtime default option automatically . The withDefaults macro still exists and is still required for certain edge cases, but it is no longer the default pattern for declaring defaults in TypeScript.

This chapter covers three areas. First, why Reactive Props Destructure exists — the historical problem with destructuring props and the compiler transform that solves it. Second, how the feature works — the compilation behavior, the native default syntax, and local aliasing with renamed destructured properties. Third, the caveats that remain — the getter requirement for watch and composables, the withDefaults edge case, and the situations where reactivity can still be lost. The chapter ends with a complete example session, a quick reference, best practices, common pitfalls, real-world examples, and diagrams showing the compilation flow.

Key point: In Vue 3.5+, destructured props are reactive because the compiler rewrites every access to props.xxx. Native default values work directly in the destructuring pattern, eliminating withDefaults for most cases. However, passing a destructured prop to watch or a composable still requires wrapping it in a getter, because the compiler cannot rewrite the argument expression.


Why Reactive Props Destructure exists

The destructuring footgun. For years, Vue developers were told not to destructure props. The official setup() documentation explicitly stated that destructured variables lose reactivity and recommended toRefs() or toRef() as the workaround . The ESLint plugin shipped a rule (vue/no-setup-props-reactivity-loss) to catch the mistake . The problem was fundamental: defineProps() returns a reactive object, and destructuring is a one-time read. The value is copied out, and the connection to the reactive source is severed. The variable is a snapshot, not a live binding.

The withDefaults friction. TypeScript interfaces cannot contain runtime values, so the type-based defineProps<T>() syntax had no way to declare default values. Vue 3.4 introduced withDefaults() as a workaround: a separate macro that takes the defineProps result and an object of defaults. It worked, but it was clunky. The defaults were declared in a separate location from the props, and the two had to be kept in sync manually. The community feedback was consistent: the pattern was verbose and error-prone .

The compiler transform. The solution was to make the compiler do the work. When it sees defineProps() being destructured, it rewrites the destructuring and every subsequent access to the destructured variables. The rewrite is mechanical: const { count } = defineProps(...) becomes a declaration that accesses props.count on every reference. The developer writes destructuring syntax, and the compiler produces props.xxx access . The feature was experimental for about a year, and when it was stabilized in 3.5, Evan You noted that developers who used it at scale “came back with positive feedback” .

The default-value simplification. The compiler transform made it possible to support native JavaScript default values in the destructuring pattern. const { count = 0 } = defineProps<{ count?: number }>() is valid JavaScript, and the compiler translates it into the runtime default: 0 option. The withDefaults macro is no longer needed for this case, because the default is expressed in the same syntax that JavaScript already uses for destructuring defaults . The result is less code and a more natural reading experience.

The trade-off. The feature is not without caveats. The compiler rewrites variable access, which means the variables are not “real” in the way a ref is. Passing a destructured prop to watch or a composable requires wrapping it in a getter, because the compiler cannot rewrite the argument expression inside the function call . There is also a known bug where destructuring a prop that was declared with withDefaults loses reactivity, because the compiler does not handle the combination correctly . And the feature can be disabled entirely with a Vite configuration flag for teams that prefer the old pattern .


a. The compilation behavior

The compiler transforms destructured props so that every access is a reactive read. When you write const { count } = defineProps(...), the declaration itself is removed, and every reference to count in the script or template is compiled into props.count .

<script setup lang="ts">
const { count, msg } = defineProps<{
  count: number;
  msg: string;
}>();
</script>

<template>
  <p>{{ count }} - {{ msg }}</p>
</template>

The compiler produces something equivalent to:

export default {
  props: {
    count: { type: Number, required: true },
    msg: { type: String, required: true },
  },
  setup(props) {
    // The destructuring is removed.
    // Every access to count or msg is rewritten.
    return () => h('p', `${props.count} - ${props.msg}`);
  },
};

The destructured variables do not exist as JavaScript variables in the compiled output. They are aliases that the compiler resolves to props.xxx access. This is why they remain reactive: every read goes through the reactive proxy, and the dependency is tracked .

The compiler also handles local aliasing in the destructuring pattern. If you want to rename a prop, you can use the standard JavaScript aliasing syntax:

<script setup lang="ts">
const { title: headline } = defineProps<{ title: string }>();
</script>

<template>
  <h1>{{ headline }}</h1>
</template>

Every reference to headline is compiled to props.title . The alias is a local name for the reactive prop.


b. Native default values

With Reactive Props Destructure, default values are declared directly in the destructuring pattern using JavaScript’s native syntax. The compiler translates the default into the runtime default option .

<script setup lang="ts">
const {
  count = 0,
  msg = 'hello',
  items = () => [],
} = defineProps<{
  count?: number;
  msg?: string;
  items?: string[];
}>();
</script>

The compiler generates the equivalent runtime declaration:

export default {
  props: {
    count: { type: Number, required: false, default: 0 },
    msg: { type: String, required: false, default: 'hello' },
    items: { type: Array, required: false, default: () => [] },
  },
  setup(props) {
    // ...
  },
};

The withDefaults macro is not needed for this case. The default values are declared once, in the same place as the props themselves. For mutable reference types like arrays and objects, the default must still be a factory function, and the compiler enforces this .

The before-and-after comparison is striking. Vue 3.4 required this:

const props = withDefaults(
  defineProps<{
    count?: number;
    msg?: string;
  }>(),
  {
    count: 0,
    msg: 'hello',
  }
);

Vue 3.5 allows this:

const { count = 0, msg = 'hello' } = defineProps<{
  count?: number;
  msg?: string;
}>();

The second version is shorter and more natural. The default values are expressed in the syntax that JavaScript already uses for destructuring defaults, so there is no new API to learn .


c. The caveats that remain

The compiler transform is powerful, but it cannot rewrite everything. The remaining caveats are the source of most confusion with the feature.

Watching a destructured prop. The watch function does not accept a plain value as its source. It accepts a reactive source: a ref, a reactive object, a getter function, or an array of these. Passing count to watch produces a compile-time error, because count is not a reactive source; it is a compiled alias that the compiler cannot pass as a function .

watch(count /* ... */)
//    ^ results in compile-time error

The correct pattern is to wrap the destructured prop in a getter:

watch(() => count /* ... */)
//    ^ works as expected

The getter is re-evaluated by the watcher, and each evaluation reads the current value of count, which the compiler rewrites to props.count. The dependency is tracked, and the watcher fires when the prop changes .

Passing a destructured prop to a composable. The same issue applies to composables. A composable that expects a reactive source cannot receive the destructured variable directly, because the compiler cannot rewrite the argument inside the function call. The composable should be designed to accept a MaybeRef and normalize it with toValue(), and the caller should pass a getter .

// composables should normalize the input with `toValue()`
useDynamicCount(() => count)

The withDefaults bug. There is a known issue where destructuring a prop that was declared with withDefaults loses reactivity. The compiler does not handle the combination correctly, and the destructured variable is not rewritten to props.xxx .

// Reactivity is lost
const { msg } = withDefaults(defineProps<{ msg: string }>(), { msg: 'Hello Comp!!' })

// Reactivity is not lost
const { msg = 'Hello Comp!!' } = defineProps<{ msg?: string }>()

The fix is to use the native default syntax instead of withDefaults. If withDefaults is required for some reason (a complex type that the compiler cannot analyze), the destructured variables should be accessed through props.xxx instead of destructured .

Disabling the feature. The feature can be disabled with a Vite configuration flag for teams that prefer the old pattern of accessing props through the props object .

// vite.config.js
export default {
  plugins: [
    vue({
      script: {
        propsDestructure: false,
      },
    }),
  ],
};

Complete Example Session

<!-- ============================================
     PART 1: BASIC DESTRUCTURING WITH DEFAULTS
     ============================================ -->

<script setup lang="ts">
const {
  title,
  count = 0,
  items = () => [],
} = defineProps<{
  title: string;
  count?: number;
  items?: string[];
}>();
</script>

<template>
  <h1>{{ title }}</h1>
  <p>Count: {{ count }}</p>
  <ul>
    <li v-for="item in items" :key="item">{{ item }}</li>
  </ul>
</template>

<!-- The compiler rewrites count and items access
     to props.count and props.items.
     The defaults are compiled into runtime default options. -->


<!-- ============================================
     PART 2: LOCAL ALIASING
     ============================================ -->

<script setup lang="ts">
const { title: headline, count: total } = defineProps<{
  title: string;
  count: number;
}>();
</script>

<template>
  <h1>{{ headline }}</h1>
  <p>Total: {{ total }}</p>
</template>

<!-- headline is compiled to props.title.
     total is compiled to props.count. -->


<!-- ============================================
     PART 3: WATCHING A DESTRUCTURED PROP
     ============================================ -->

<script setup lang="ts">
import { watch } from 'vue';

const { count } = defineProps<{ count: number }>();

// ❌ This would be a compile-time error:
// watch(count, (newVal) => { ... })

// ✅ Wrap in a getter:
watch(() => count, (newVal, oldVal) => {
  console.log(`count changed from ${oldVal} to ${newVal}`);
});
</script>


<!-- ============================================
     PART 4: PASSING TO A COMPOSABLE
     ============================================ -->

<script setup lang="ts">
import { toValue } from 'vue';

function useDynamicCount(source: () => number) {
  // toValue unwraps refs, getters, and plain values
  const value = toValue(source);
  // ...
}

const { count } = defineProps<{ count: number }>();

// ✅ Pass a getter:
useDynamicCount(() => count);
</script>


<!-- ============================================
     PART 5: THE withDefaults BUG
     ============================================ -->

<script setup lang="ts">
// ❌ Reactivity is lost with this pattern:
// const { msg } = withDefaults(defineProps<{ msg: string }>(), { msg: 'Hello' })

// ✅ Use native default syntax instead:
const { msg = 'Hello' } = defineProps<{ msg?: string }>();
</script>

<template>
  <p>{{ msg }}</p>
</template>

<!-- The native default syntax is compiled correctly.
     The withDefaults combination is a known bug. -->


<!-- ============================================
     PART 6: DESTRUCTURED PROPS IN COMPUTED
     ============================================ -->

<script setup lang="ts">
import { computed } from 'vue';

const { firstName, lastName } = defineProps<{
  firstName: string;
  lastName: string;
}>();

const fullName = computed(() => `${firstName} ${lastName}`);
</script>

<template>
  <p>{{ fullName }}</p>
</template>

<!-- Inside the computed getter, firstName and lastName
     are compiled to props.firstName and props.lastName.
     The computed tracks the props and re-evaluates
     when they change. -->


<!-- ============================================
     PART 7: REACTIVE DESTRUCTURE IN TEMPLATE
     ============================================ -->

<script setup lang="ts">
const { count } = defineProps<{ count: number }>();
</script>

<template>
  <!-- The template access is also compiled to props.count -->
  <p>{{ count }}</p>
</template>

<!-- Both the script access and the template access
     are rewritten to props.count. -->


<!-- ============================================
     PART 8: DISABLING THE FEATURE
     ============================================ -->

// vite.config.js
export default {
  plugins: [
    vue({
      script: {
        propsDestructure: false,
      },
    }),
  ],
};

// With the feature disabled, destructuring loses reactivity
// and the compiler emits a warning.
// The old pattern of props.xxx access is required.


<!-- ============================================
     PART 9: DESTRUCTURING MULTIPLE PROPS WITH DEFAULTS
     ============================================ -->

<script setup lang="ts">
const {
  variant = 'primary',
  size = 'medium',
  disabled = false,
  label = 'Button',
} = defineProps<{
  variant?: 'primary' | 'secondary' | 'danger';
  size?: 'small' | 'medium' | 'large';
  disabled?: boolean;
  label?: string;
}>();
</script>

<template>
  <button
    :class="`btn btn--${variant} btn--${size}`"
    :disabled="disabled"
  >
    {{ label }}
  </button>
</template>


<!-- ============================================
     PART 10: THE COMPLETE COMPONENT
     ============================================ -->

<script setup lang="ts">
import { computed } from 'vue';

const {
  title,
  count = 0,
  items = () => [],
  variant = 'default',
} = defineProps<{
  title: string;
  count?: number;
  items?: Array<{ id: number; name: string }>;
  variant?: 'default' | 'compact';
}>();

const total = computed(() => items.length);

const isCompact = computed(() => variant === 'compact');
</script>

<template>
  <div :class="{ compact: isCompact }">
    <h2>{{ title }}</h2>
    <p>Count: {{ count }} | Total items: {{ total }}</p>
    <ul>
      <li v-for="item in items" :key="item.id">{{ item.name }}</li>
    </ul>
  </div>
</template>

The ten parts show basic destructuring with defaults, local aliasing, watching a destructured prop, passing to a composable, the withDefaults bug, destructured props in computed, template access, disabling the feature, multiple defaults, and a complete component.


Quick Reference

Compilation Behavior

SourceCompiled to
const { count } = defineProps(...)Every count access → props.count
const { count = 0 } = defineProps(...)default: 0 in runtime props
const { title: headline } = defineProps(...)Every headline access → props.title

Native Default Syntax

TypeDefaultValid
count = 0Number✅
msg = 'hello'String✅
items = () => []Array✅
config = { a: 1 }Object❌ (must be factory)

Caveats

SituationProblemFix
watch(count, ...)Compile-time errorwatch(() => count, ...)
useComposable(count)Reactivity lostuseComposable(() => count)
withDefaults + destructureReactivity lostUse native defaults
Feature disabledDestructure loses reactivityUse props.xxx

Version Requirements

FeatureVersion
Reactive Props Destructure stable3.5+
Enabled by default3.5+
Disable flagVite plugin config

Best Practices

✅ Do This:

<script setup lang="ts">
// Use native default syntax
const { count = 0, msg = 'hello' } = defineProps<Props>();               // ✅
// Use local aliasing for readability
const { title: headline } = defineProps<{ title: string }>();            // ✅
// Wrap in getter for watch
watch(() => count, (newVal) => { ... });                                 // ✅
// Wrap in getter for composables
useDynamicCount(() => count);                                            // ✅
// Use factory for mutable defaults
const { items = () => [] } = defineProps<Props>();                       // ✅
</script>

❌ Don’t Do This:

<script setup lang="ts">
// Don't use withDefaults + destructure
const { msg } = withDefaults(defineProps<{ msg: string }>(), { msg: 'Hi' }); // ❌
// Don't pass destructured prop to watch directly
watch(count, (newVal) => { ... }); // compile-time error                  // ❌
// Don't use non-factory default for mutable types
const { items = [] } = defineProps<{ items?: string[] }>();               // ❌
// Don't expect destructured vars to be refs
count.value // count is a compiled alias, not a ref                        // ❌
// Don't destructure if you need to pass the whole props object
const { count } = defineProps(...); useSomething(props); // props undefined // ❌
</script>

Common Pitfalls

PitfallWhy It HappensFix
count.value is undefinedDestructured var is not a refUse count directly
watch(count) compile errorWatch expects a reactive sourceWrap in getter: () => count
Reactivity lost with withDefaultsKnown bug in the combinationUse native default syntax
Composable doesn’t updatePassed plain value instead of getterPass () => count
props is undefinedDestructuring removed the props variableDon’t destructure if you need props
Default not appliedUsed direct reference for mutable defaultUse factory function

Real-World Examples

1. Basic Destructure with Defaults

const { count = 0 } = defineProps<{ count?: number }>();

2. Local Aliasing

const { title: headline } = defineProps<{ title: string }>();

3. Watch with Getter

watch(() => count, (newVal) => { ... });

4. Composable with Getter

useDynamicCount(() => count);

5. Factory Default

const { items = () => [] } = defineProps<{ items?: string[] }>();

6. Computed from Destructured

const fullName = computed(() => `${firstName} ${lastName}`);

7. Template Access

<template>{{ count }}</template>

8. Disable Feature

vue({ script: { propsDestructure: false } })

9. Multiple Defaults

const { variant = 'primary', size = 'medium' } = defineProps<Props>();

10. The withDefaults Bug

// ❌ const { msg } = withDefaults(defineProps<...>(), { msg: 'Hi' })
// ✅ const { msg = 'Hi' } = defineProps<...>()

Visual

The Compiler Transform

┌──────────────────────────────────────────────────────────────┐
│  COMPILER TRANSFORM                                          │
│                                                              │
│  Source:                                                     │
│  ┌──────────────────────────────────────────────────────┐    │
│  │  const { count = 0, msg = 'hello' }                  │    │
│  │    = defineProps<{ count?: number, msg?: string }>() │    │
│  │                                                       │    │
│  │  console.log(count);                                 │    │
│  │  watch(() => count, (v) => { ... });                 │    │
│  └──────────────────────────────────────────────────────┘    │
│    │                                                         │
│    ▼                                                         │
│  Compiled:                                                   │
│  ┌──────────────────────────────────────────────────────┐    │
│  │  export default {                                    │    │
│  │    props: {                                          │    │
│  │      count: { type: Number, default: 0 },            │    │
│  │      msg: { type: String, default: 'hello' }         │    │
│  │    },                                                │    │
│  │    setup(props) {                                    │    │
│  │      console.log(props.count);                       │    │
│  │      watch(() => props.count, (v) => { ... });       │    │
│  │    }                                                 │    │
│  │  }                                                   │    │
│  └──────────────────────────────────────────────────────┘    │
│                                                              │
│  The destructured variables are removed.
│  Every access is rewritten to props.xxx.
│                                                              │
└──────────────────────────────────────────────────────────────┘

Native Default vs withDefaults

┌──────────────────────────────────────────────────────────────┐
│  NATIVE DEFAULT vs WITHDEFAULTS                              │
│                                                              │
│  withDefaults (Vue 3.4):                                     │
│  ┌──────────────────────────────────────────────────────┐    │
│  │  const props = withDefaults(                         │    │
│  │    defineProps<{ count?: number }>(),                │    │
│  │    { count: 0 }                                      │    │
│  │  )                                                   │    │
│  │                                                       │    │
│  │  Access: props.count                                 │    │
│  └──────────────────────────────────────────────────────┘    │
│                                                              │
│  Native Default (Vue 3.5):                                   │
│  ┌──────────────────────────────────────────────────────┐    │
│  │  const { count = 0 } = defineProps<{ count?: number }>()│  │
│  │                                                       │    │
│  │  Access: count (compiled to props.count)             │    │
│  └──────────────────────────────────────────────────────┘    │
│                                                              │
│  The native syntax is shorter, more natural, and
│  does not require a separate macro.                          │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Getter Requirement for Watch

┌──────────────────────────────────────────────────────────────┐
│  GETTER REQUIREMENT FOR WATCH                                │
│                                                              │
│  const { count } = defineProps<{ count: number }>();         │
│                                                              │
│  ❌ WRONG:                                                   │
│  ┌──────────────────────────────────────────────────────┐    │
│  │  watch(count, (newVal) => { ... })                   │    │
│  │                                                       │    │
│  │  Compile-time error:                                 │    │
│  │  "count" is not a reactive source.                   │    │
│  └──────────────────────────────────────────────────────┘    │
│                                                              │
│  ✅ CORRECT:                                                 │
│  ┌──────────────────────────────────────────────────────┐    │
│  │  watch(() => count, (newVal) => { ... })             │    │
│  │                                                       │    │
│  │  The getter reads count on each evaluation.          │    │
│  │  The compiler rewrites it to props.count.            │    │
│  │  The dependency is tracked.                          │    │
│  └──────────────────────────────────────────────────────┘    │
│                                                              │
│  The compiler cannot rewrite the argument expression.
│  It can only rewrite variable access.                        │
│                                                              │
└──────────────────────────────────────────────────────────────┘

The withDefaults Bug

┌──────────────────────────────────────────────────────────────┐
│  THE WITHDEFAULTS BUG                                        │
│                                                              │
│  ❌ BROKEN:                                                   │
│  ┌──────────────────────────────────────────────────────┐    │
│  │  const { msg } = withDefaults(                       │    │
│  │    defineProps<{ msg: string }>(),                   │    │
│  │    { msg: 'Hello' }                                  │    │
│  │  )                                                   │    │
│  │                                                       │    │
│  │  Reactivity is lost. The compiler does not           │    │
│  │  rewrite destructured variables from withDefaults.   │    │
│  └──────────────────────────────────────────────────────┘    │
│                                                              │
│  ✅ WORKING:                                                  │
│  ┌──────────────────────────────────────────────────────┐    │
│  │  const { msg = 'Hello' } = defineProps<{ msg?: string }>()
│  │                                                       │    │
│  │  The native syntax is handled correctly by           │    │
│  │  the compiler.                                       │    │
│  └──────────────────────────────────────────────────────┘    │
│                                                              │
│  Fix: use the native default syntax instead of
│  withDefaults when destructuring.
│                                                              │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
Reactive Props DestructureStable in Vue 3.5, enabled by default
CompilationEvery destructured access → props.xxx
Native defaultsconst { count = 0 } = defineProps(...)
Local aliasingconst { title: headline } = defineProps(...)
withDefaultsNot needed for native defaults; buggy with destructure
watchMust wrap in getter: () => count
ComposablesMust pass getter: useDynamicCount(() => count)
Disable flagvue({ script: { propsDestructure: false } })
VersionVue 3.5+

Key takeaways:

  • Reactive Props Destructure stabilizes destructuring. In Vue 3.5, destructured props are reactive because the compiler rewrites every access to props.xxx. The destructured variable is a compiled alias, not a disconnected copy .
  • Native default values replace withDefaults for most cases. The JavaScript destructuring default syntax works directly: const { count = 0 } = defineProps<{ count?: number }>(). The compiler generates the runtime default option .
  • Local aliasing works with standard JavaScript syntax. const { title: headline } = defineProps(...) compiles every headline access to props.title .
  • watch requires a getter. The compiler cannot rewrite the argument expression inside watch(count). The correct pattern is watch(() => count, ...), which reads the current value on each evaluation .
  • Composables should accept a getter. The composable normalizes the input with toValue(), and the caller passes () => count to retain reactivity .
  • The withDefaults combination is buggy. Destructuring a prop declared with withDefaults loses reactivity. Use the native default syntax instead .
  • The feature can be disabled. A Vite configuration flag reverts to the old behavior of accessing props through the props object .
  • The feature is opt-out, not opt-in. It is enabled by default in Vue 3.5 and later. Projects upgrading from earlier versions get the new behavior automatically, but the old pattern still works .

Remember: Vue 3.5’s Reactive Props Destructure turns a long-standing footgun into a feature. Destructuring props is now safe and reactive, because the compiler rewrites every access to props.xxx. The native default syntax eliminates the withDefaults macro for most cases, and local aliasing works as expected. The caveats are narrow: watch and composables need a getter, and the withDefaults combination is buggy. For the majority of components, the new syntax is shorter, clearer, and more natural than the old props.xxx pattern. The compiler does the work, and the developer writes the code they always wanted to write.



Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!