Deep Dive into Vue 3.5: Reactive Props Destructure and useTemplateRef
Hello, this is Lovefield.
With the official release of Vue 3.5, substantial improvements have arrived for both developer experience (DX) and runtime performance. Notably, Reactive Props Destructure—which went through extensive RFC debates and experimental trials—has stabilized and is now enabled by default. In addition, the long-standing template reference convention has been streamlined with the new useTemplateRef API.
In this post, we will walk through the core API updates in Vue 3.5, their underlying mechanics, and how to apply them to production codebases.
Reactive Props Destructure
The Prior Limitation
When writing Composition API code in <script setup>, one frequent pitfall was that destructuring the object returned by defineProps caused props to lose reactivity:
TypeScript
1
2
3
4
5
6
// ❌ Prior to Vue 3.5: Loss of reactivity
const { count, title } = defineProps<{ count: number; title?: string }>();
watch(() => count, () => {
// This callback never fires when the parent component updates `count`.
});To retain reactivity, developers were forced to either keep the namespace intact everywhere via props.count or wrap the props using toRefs(props). An earlier experimental solution, Reactivity Transform ($ref), was ultimately deprecated due to mental overhead and drift from standard JavaScript semantics.
Vue 3.5: Compiler-Level Getter Rewriting
Starting in Vue 3.5, destructuring defineProps preserves full reactivity out of the box. Default values can also be declared using standard JavaScript destructuring defaults:
Vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<script setup lang="ts">
import { watch, computed } from 'vue';
interface Props {
count: number;
title?: string;
items?: string[];
}
// ✅ Destructuring and default values defined simultaneously
const {
count,
title = 'Default Title',
items = () => ['Vue', 'Nuxt']
} = defineProps<Props>();
// 1. Direct tracking inside watchers works as expected
watch(() => count, (newCount) => {
console.log(`count changed: ${newCount}`);
});
// 2. Computed properties automatically register reactive dependencies
const doubleCount = computed(() => count * 2);
</script>
<template>
<div class="counter-box">
<h2>{{ title }}</h2>
<p>Count: {{ count }} / Double: {{ doubleCount }}</p>
<ul>
<li v-for="item in items" :key="item">{{ item }}</li>
</ul>
</div>
</template>
Under the Hood: Compiler Transformation
Vue does not use runtime magic to make primitive variables reactive. Instead, @vue/compiler-sfc intercepts access to destructured prop variables within <script setup> and transforms them into getter calls referencing the internal __props object during compilation:
JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Compiled output (conceptual illustration)
export default {
setup(__props) {
// References to `count` are rewritten to `__props.count`
_watch(() => __props.count, (newCount) => {
console.log(`count changed: ${newCount}`);
});
const doubleCount = _computed(() => __props.count * 2);
return (_ctx, _cache) => {
return _createVNode("p", null, `Count: ${__props.count}`);
};
}
}Caveat: Passing Destructured Props into Plain Functions
Because destructured props resolve through getters in local scope, passing them directly as function arguments will evaluate them down to primitive values at that exact instant, severing the reactive chain:
TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
function logValue(val: number) {
// `val` holds only a static evaluated primitive
console.log(val);
}
// ⚠️ Reactivity is severed
logValue(count);
// ✅ Wrap in a getter callback to preserve reactive tracking
function logReactiveValue(getVal: () => number) {
watch(getVal, (v) => console.log(v));
}
logReactiveValue(() => count);useTemplateRef
The Problem with Name-Matching Conventions
Previously in Vue 3, attaching a template ref required declaring a ref() variable whose identifier matched the string value in the template attribute:
Vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!-- Legacy approach -->
<script setup lang="ts">
import { ref, onMounted } from 'vue';
// Variable name must match `ref="inputEl"` in the template exactly
const inputEl = ref<HTMLInputElement | null>(null);
onMounted(() => {
inputEl.value?.focus();
});
</script>
<template>
<input ref="inputEl" type="text" />
</template>
This implicit naming linkage was brittle: renaming the template attribute often failed silently without compiler warnings, leading to runtime null errors.
Explicit Binding via useTemplateRef
Vue 3.5 introduces useTemplateRef, which takes an explicit string key matching the template ref attribute and produces a strongly typed ref:
Vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<script setup lang="ts">
import { useTemplateRef, onMounted } from 'vue';
// Explicit template ref identifier passed as argument
const searchInput = useTemplateRef<HTMLInputElement>('customInput');
onMounted(() => {
// `searchInput.value` is safely inferred as HTMLInputElement | null
searchInput.value?.focus();
});
</script>
<template>
<input ref="customInput" type="text" placeholder="Enter search term" />
</template>When referencing child component instances, combining useTemplateRef with InstanceType<typeof MyComponent> guarantees type safety for exposed methods (defineExpose):
Vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<script setup lang="ts">
import { useTemplateRef } from 'vue';
import ConfirmModal from './ConfirmModal.vue';
const modalRef = useTemplateRef<InstanceType<typeof ConfirmModal>>('confirmModal');
const handleOpen = () => {
modalRef.value?.open();
};
</script>
<template>
<ConfirmModal ref="confirmModal" />
<button @click="handleOpen">Open Modal</button>
</template>
Internal Reactivity Engine Optimizations
Beyond surface-level API additions, Vue 3.5 overhauled its internal reactivity algorithms:
~56% Memory Footprint Reduction: Dependency tracking structures transitioned to a doubly-linked list model, significantly cutting memory overhead when managing large, deeply nested datasets.
Elimination of Stale Computed Re-evaluations: Improvements to graph traversal prevent redundant recalculations of downstream computed properties when intermediate values remain unchanged.
Lazy Hydration Support: Enhanced hydration control in defineAsyncComponent (such as hydrateOnVisible / viewport triggers) minimizes initial main-thread blocking time during SSR hydration.
Summary & Migration Takeaways
Vue 3.5 eliminates boilerplates like toRefs and loose string-matching refs at the compiler level. Upgrading an existing codebase yields immediate benefits in cleaner syntax, better type inference, and reduced memory usage without breaking existing component contracts. If you are preparing to modernize your Vue stack, migrating to these patterns is highly recommended.
This article was generated by AI.

Lovefield
Web Front-End developer
하고싶은게 많고, 나만의 서비스를 만들고 싶은 변태스러운 개발자입니다.

A Two-Year Sandbox Where Failure Is Allowed: Attending the 1st SK AIGEN Information Session
ETC