You're building a Vue component and suddenly need direct access to a DOM element or a child component's method. Template refs are the answer, but they're often misused or avoided altogether. In this guide, we'll walk through everything you need to know about template refs—from the modern useTemplateRef API in Vue 3.5+ to the architectural patterns that keep your code maintainable and testable.
useTemplateRef in Vue 3.5+
Vue 3.5 introduced useTemplateRef, a composable that replaces the older string-based ref syntax. Instead of declaring refs in your setup function and hoping they match template attributes, you now get type-safe, reactive references that are far easier to track and refactor.
Here's the pattern: call useTemplateRef with a string key that matches your template ref attribute. The composable returns a ref object that points to the underlying DOM element or component instance.
<template>
<input ref="searchInput" type="text" placeholder="Search..." />
<button @click="focusSearch">Focus Search</button>
</template>
<script setup lang="ts">
import { useTemplateRef } from 'vue'
const searchInput = useTemplateRef<HTMLInputElement>('searchInput')
const focusSearch = () => {
searchInput.value?.focus()
}
</script>Notice the optional chaining: searchInput.value?.focus(). This is essential because the ref may be null before the component mounts or if the element isn't rendered. The generic type <HTMLInputElement> gives you full TypeScript support and IDE autocomplete for DOM methods and properties.
In a Nuxt context, this works identically. The composable is available whether you're in a page component, a layout, or a shared component. The key difference is that Nuxt's auto-imports mean you don't need to import useTemplateRef explicitly—though we recommend doing so for clarity.
Available after mount
A common source of bugs is accessing a ref before the component has mounted. At that point, value is null, and calling methods on it throws an error.
Understanding the lifecycle is crucial. When your setup function runs, refs exist but their values are not yet populated. The template hasn't rendered, and the DOM elements don't exist. Only after the component is mounted does Vue populate each ref with the actual element or component instance.
<template>
<div>
<canvas ref="canvas"></canvas>
<p>{{ status }}</p>
</div>
</template>
<script setup lang="ts">
import { useTemplateRef, onMounted, ref } from 'vue'
const canvas = useTemplateRef<HTMLCanvasElement>('canvas')
const status = ref<string>('initializing')
onMounted(() => {
if (!canvas.value) {
status.value = 'canvas not found'
return
}
const ctx = canvas.value.getContext('2d')
if (ctx) {
ctx.fillStyle = '#ff0000'
ctx.fillRect(10, 10, 50, 50)
status.value = 'canvas drawn'
}
})
</script>By placing the ref access inside onMounted, we guarantee the DOM is ready. The pattern is straightforward: perform any ref-dependent initialization in lifecycle hooks, never in setup or computed properties that run before mount.
For Nuxt, this behavior is consistent across pages and layouts. A page component's refs are populated after Nuxt's hydration completes, which happens after the Vue mount lifecycle.
defineExpose to share child methods
Sometimes a parent component needs to call a method on a child. Rather than passing callbacks down through props (which becomes prop-drilling hell), you can expose methods from the child using defineExpose.
The child component explicitly declares which data and methods are accessible to parents. This is a form of public API for the component.
// ChildComponent.vue
<template>
<div>
<input ref="input" type="text" />
</div>
</template>
<script setup lang="ts">
import { useTemplateRef } from 'vue'
const input = useTemplateRef<HTMLInputElement>('input')
const clear = () => {
if (input.value) {
input.value.value = ''
}
}
const getInputValue = () => {
return input.value?.value ?? ''
}
defineExpose({
clear,
getInputValue
})
</script>Now in the parent, you get a ref to the entire child component instance and can call those exposed methods:
// ParentComponent.vue
<template>
<div>
<ChildComponent ref="child" />
<button @click="handleClear">Clear Child</button>
<button @click="handleGetValue">Get Value</button>
</div>
</template>
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import ChildComponent from './ChildComponent.vue'
const child = useTemplateRef<InstanceType<typeof ChildComponent>>('child')
const handleClear = () => {
child.value?.clear()
}
const handleGetValue = () => {
const val = child.value?.getInputValue()
console.log('Child input value:', val)
}
</script>This pattern is useful for form resets, animations, or any operation where a child component needs to perform an action initiated by the parent. In Nuxt apps, this is especially common in modal or dialog components—a parent layout might expose a method to close all modals.
Do not overuse — prefer props and events
Template refs are powerful but can become a maintenance nightmare if overused. The Vue team's guidance is clear: use props to send data down and events to send data up. Reach for refs only when there's no cleaner alternative.
Why? Because refs bypass Vue's declarative data flow. When you access a ref directly, the parent and child lose the clear contract defined by props and events. Testing becomes harder, refactoring becomes risky, and future developers (including you) have to trace ref usage to understand what's happening.
Here's an example of misuse:
// Anti-pattern: parent directly mutating child state via ref
<template>
<Counter ref="counter" />
<button @click="badApproach">Add 5</button>
</template>
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import Counter from './Counter.vue'
const counter = useTemplateRef<InstanceType<typeof Counter>>('counter')
const badApproach = () => {
// Directly mutating child state
counter.value.count += 5
}
</script>The better approach uses an event:
// Child: Counter.vue
<template>
<div>
<p>Count: {{ count }}</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
defineExpose({
increment: (amount: number) => {
count.value += amount
}
})
</script>
// Parent
<template>
<div>
<Counter ref="counter" />
<button @click="() => counter.value?.increment(5)">Add 5</button>
</div>
</template>
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import Counter from './Counter.vue'
const counter = useTemplateRef<InstanceType<typeof Counter>>('counter')
</script>Even better: use an event emitter so the parent has zero direct knowledge of the child's internal state:
// Child: Counter.vue
<template>
<div>
<p>Count: {{ count }}</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
const emit = defineEmits<{
change: [value: number]
}>()
const increment = (amount: number) => {
count.value += amount
emit('change', count.value)
}
defineExpose({ increment })
</script>
// Parent
<template>
<div>
<Counter ref="counter" @change="onCountChange" />
<button @click="() => counter.value?.increment(5)">Add 5</button>
</div>
</template>
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import Counter from './Counter.vue'
const counter = useTemplateRef<InstanceType<typeof Counter>>('counter')
const onCountChange = (newValue: number) => {
console.log('Count changed to:', newValue)
}
</script>Use refs for side effects that genuinely require them: focusing an input, triggering an animation, accessing canvas or video elements, or integrating with third-party libraries that need direct DOM access.
Common mistakes
Accessing refs before mount: Always check that ref.value is not null before using it. Wrap ref-dependent code in onMounted or use optional chaining.
Using refs for prop-like communication: If you're calling methods to update child state frequently, you should be using props and events instead. Refs are not a replacement for reactive data flow.
Forgetting to type refs properly: Skip the generic type and you lose TypeScript support. Always specify the element type or component type when creating a ref, especially for custom components.
Exposing too much from children: defineExpose should reveal a minimal public interface. Exposing all internal data and methods defeats the purpose of encapsulation.
Not handling reactive changes: If a ref points to a component that conditionally renders, the ref value may become null at runtime. Always guard against this.
Key takeaways
useTemplateRef(Vue 3.5+) provides a type-safe, composable way to access DOM elements and component instances.- Refs are populated only after the component mounts; always use
onMountedor optional chaining to avoid null reference errors. defineExposeallows children to explicitly share methods with parents, but keep the public API minimal.- Prefer props and events for data flow; use refs only when direct DOM or component access is genuinely necessary.
- In Nuxt, refs work the same way as in Vue, following the standard component lifecycle and composition API patterns.






Comments
Loading comments…