In Vue 3.5, a new built-in composable was added: useTemplateRef
.
It’s a much better way to access DOM elements directly than what we had before.
Here’s the most basic example:
<script setup>import { useTemplateRef, onMounted } from 'vue'const searchInput = useTemplateRef('search')onMounted(() => {if (!focusRef.value) return// Focus on the search input when the component mountssearchInput.value.focus()})</script><template><div><input ref="search" /></div></template>
When this component mounts, we’ll immediately put focus into the search input
. To do this, we first set up our template ref using useTemplateRef
and passing it the key “search”. Then, in our template, we add a ref
attribute with the same key to our input
element.
Behind the scenes, Vue connects these two pieces, and now we can directly access our input element in our Vue code!
Before we move on to more advanced ways to use this and some best practices, let’s first talk about why this is important and useful.
Template refs aren’t new to Vue. Before, we would access them by declaring a regular ref
like this:
<script setup>import { ref, onMounted } from 'vue'const searchInput = ref(null)onMounted(() => {if (!focusRef.value) return// Focus on the search input when the component mountssearchInput.value.focus()})</script><template><div><input ref="searchInput" /></div></template>
There are two main differences here:
ref(null)
instead of useTemplateRef('search')
ref
attribute to the variable itself instead of using a stringThe code itself isn’t all that different, but this implementation presents us with three different challenges:
First, we can’t tell the difference between a “regular” ref, and a template ref. We have to look at the template to see how it’s being used. We can’t just look at the script block to get that info.
With useTemplateRef
, we know at a glance, because it’s defined using useTemplateRef
instead of ref
like everything else is.
Second, writing composables that manipulate the DOM requires passing these template refs back and forth all of the time. If we took this focus example and bundled it up into a composable, we’d get this:
import { onMounted, ref } from 'vue'export default function useFocus(focusRef) {onMounted(() => {// Make sure we were given a refif (focusRef.value) {focusRef.value.focus()}})}
To use it, we first have to create the ref and then pass it in:
<script setup>import { ref } from 'vue'import useFocus from './useFocus.js'const searchInput = ref(null)useFocus(searchInput)</script><template><div><input ref="searchInput" /></div></template>
Instead, with useTemplateRef
, we can refactor it to be simpler and more flexible as well:
import { onMounted, useTemplateRef } from 'vue'export default function useFocus() {const focusRef = useTemplateRef('focus')onMounted(() => {if (!focusRef.value) returnfocusRef.value.focus()})}
We’ll also have to update our template to use the “focus” key:
<script setup>import useFocus from './useFocus.js'useFocus()</script><template><div><input ref="focus" /></div></template>
With this update, we no longer have to pass the ref around! There are some other cool things we can do when writing composables with useTemplateRef
, but we’ll tackle that later on in this article.
Lastly, the useTemplateRef
composable gives us way better type inference in two ways:
If we use useTemplateRef
in a component or a composable (and the element we use it on is static), we get automatic type inference of that element. This means that if it’s a textarea
we get the HTMLTextAreaElement
type, and if it’s a div
we’ll get back HTMLDivElement
.
We also get fantastic autocomplete for the keys we pass into useTemplateRef
, because TypeScript knows all about how it should be connected. This makes for a really great developer experience!
Okay, now that I’ve convinced you why we need it, let’s take a step deeper and look at how we can use this in our apps.
We’ve already seen the most basic way to use it:
<script setup>import { useTemplateRef, onMounted } from 'vue'const searchInput = useTemplateRef('search')onMounted(() => {if (!searchInput.value) return// Focus on the search input when the component mountssearchInput.value.focus()})</script><template><div><input ref="search" /></div></template>
There are a few things we need to do:
useTemplateRef
onMounted
to make sure the component has been rendered before we try to do anything with itnull
, especially if you’re using v-if
on the element!We can also use the created template ref like we did with previous template refs, and use it directly on the template:
<script setup>import { useTemplateRef, onMounted } from 'vue'const searchInputRef = useTemplateRef('search')onMounted(() => {if (!searchInputRef.value) return// Focus on the search input when the component mountssearchInputRef.value.focus()})</script><template><div><input :ref="searchInputRef" /></div></template>
If you use useTemplateRef
inside of a v-for
, you’ll actually get back an array of elements to work with. But be careful here, there’s no guarantee that they’ll be in the same order, so you’ll have to do a bit of extra work to keep things sorted:
<script setup>import { useTemplateRef, onMounted, ref } from 'vue'const questions = ref([{ text: 'What is your mother\'s maiden name?', id: 'name' },{ text: 'What is your birthday?', id: 'birthday' },{ text: 'What street did you grow up on?', id: 'street' },]);const inputRefs = useTemplateRef('inputs')onMounted(() => {// Do something with the array of template refs})</script><template><divv-for="question in questions":key="question.id">{{ question.text }}<input ref="inputs" /></div></template>
(I’ve simplified the above example to keep it clear, but make sure your forms are actually accessible!)
Sometimes you’ll be making changes that cause a layout shift, or something else where you need to wait for the page to fully update before doing a second thing with it. In this case, nextTick
is your friend:
<script setup>import { useTemplateRef, onMounted, nextTick } from 'vue'const templateRef = useTemplateRef('element')onMounted(async () => {// Let the browser calculate the height of the elementtemplateRef.value.style.height = 'auto'// Wait for the layout to be recalculatedawait nextTick()// Lock in the height so it doesn't change after thistemplateRef.value.style.height = `${templateRef.value.scrollHeight}px`})</script><template><div><div ref="element" /></div></template>
I’ll provide a more complex example of a composable that uses all of these techniques at the end of the article, but for now let’s explore some other things we can do with useTemplateRefs
.
Let’s go one step further and apply the Flexible Arguments Pattern so we can choose whether we’ll pass in our own template ref or not:
import { onMounted, useTemplateRef } from 'vue'export default function useFocus(passedInRef) {const focusRef = passedInRef || useTemplateRef('focus')onMounted(() => {if (!focusRef.value) returnfocusRef.value.focus()})return focusRef}
Normally, we’d use ref
to make sure we’re always getting a ref in our composable:
// Normal usage of the Flexible Arguments Patternexport default function useComposable(myRef) {const internalRef = ref(myRef)}
But here, we can’t pass a ref into useTemplateRef
so we have to use a good old fashioned OR to check and create our own template ref if needed.
We can make this even better though. We can modify our input so we can pass either a string or a ref, so we can control what the key is:
import { onMounted, useTemplateRef, isRef } from 'vue'export default function useFocus(refOrString) {let focusRefif (isRef(refOrString) {// Use the template ref we've been givenfocusRef = refOrString} else if (typeof refOrString === 'string') {// Use the string to create our own template reffocusRef = useTemplateRef(refOrString)} else {// Create our own template ref with our own keyfocusRef = useTemplateRef('focus')}onMounted(() => {if (!focusRef.value) returnfocusRef.value.focus()})// Return the template refreturn focusRef}
This gives us ultimate control over how we use this composable. We can pass in our own template ref:
<script setup>import useFocus from './useFocus.js'import { useTemplateRef } from 'vue'const el = useTemplateRef('some-other-key')useFocus(el)</script><template><div><input ref="some-other-key" /></div></template>
Or we can pass in our own string:
<script setup>import useFocus from './useFocus.js'import { useTemplateRef } from 'vue'const key = 'some-other-key'useFocus(key)</script><template><div><!-- We can also use a variable with the ref attribute --><input :ref="key" /></div></template>
And if we don’t really care, we can use the default ref. But we have to know what string the composable is using as the key:
<script setup>import useFocus from './useFocus.js'useFocus()</script><template><div><input ref="focus" /></div></template>
To solve that, we can update our composable to return the default key. While we’re at it, we can also make sure that it returns whatever key is currently being used, whether we’ve passed in our own or are using the default:
import { onMounted, useTemplateRef, isRef } from 'vue'const defaultKey = 'focus'export default function useFocus(refOrString) {let focusReflet key = defaultKeyif (isRef(refOrString) {// Use the template ref we've been givenfocusRef = refOrString} else if (typeof refOrString === 'string') {// Use the string to create our own template reffocusRef = useTemplateRef(refOrString)// Update the key that we're usingkey = refOrString} else {// Create our own template ref with our own keyfocusRef = useTemplateRef(defaultKey)}onMounted(() => {if (!focusRef.value) returnfocusRef.value.focus()})return {ref: focusRef,key,};}
Now we don’t have to guess at what the composable will be using as the key:
<script setup>import useFocus from './useFocus.js'const { key } = useFocus()</script><template><div><input :ref="key" /></div></template>
Here’s an example composable from Mastering Nuxt that we use to add some scrolling functionality to our AI chat app. It shows and hides a button that is used to scroll to the bottom, and if we’re already at the bottom, keeps us there as new messages come in:
/*** A composable function that handles chat scroll behavior including:* - Tracking scroll position* - Smooth scrolling to bottom* - Auto-scrolling on new messages* - Scroll button visibility*/export default function useChatScroll() {// Template refs for accessing DOM elementsconst scrollContainer = useTemplateRef<HTMLDivElement>('scrollContainer')const textareaRef =useTemplateRef<HTMLTextAreaElement>('textareaRef')// Reactive state for tracking scroll positionconst isAtBottom = ref(true)const showScrollButton = ref(false)/*** Checks if the chat is scrolled to the bottom* Considers the chat "at bottom" if within 200px of the bottom* Updates both isAtBottom and showScrollButton states*/const checkScrollPosition = (): void => {if (scrollContainer.value) {const { scrollTop, scrollHeight, clientHeight } =scrollContainer.valueisAtBottom.value =scrollTop + clientHeight >= scrollHeight - 200showScrollButton.value = !isAtBottom.value}}/*** Smoothly scrolls the chat container to the bottom* @param immediate - If true, scrolls instantly without animation*/const scrollToBottom = (immediate = false): void => {if (!scrollContainer.value) return// Calculate the target scroll position (bottom of container)const targetScrollTop =scrollContainer.value.scrollHeight -scrollContainer.value.clientHeight// If immediate scroll requested, do it instantlyif (immediate) {scrollContainer.value.scrollTop = targetScrollTopreturn}// Setup for smooth scrolling animationconst startScrollTop = scrollContainer.value.scrollTopconst distance = targetScrollTop - startScrollTopconst duration = 300 // Animation duration in milliseconds// Animation frame handler for smooth scrollingconst startTime = performance.now()function step(currentTime: number): void {const elapsed = currentTime - startTimeconst progress = Math.min(elapsed / duration, 1)// Cubic easing function for smooth acceleration/decelerationconst easeInOutCubic =progress < 0.5? 4 * progress * progress * progress: 1 - Math.pow(-2 * progress + 2, 3) / 2if (scrollContainer.value) {scrollContainer.value.scrollTop =startScrollTop + distance * easeInOutCubic// Continue animation if not completeif (progress < 1) {requestAnimationFrame(step)}}}requestAnimationFrame(step)}/*** Forces scroll to bottom when new messages arrive* Only scrolls if already at bottom to prevent disrupting user's reading*/async function pinToBottom() {if (isAtBottom.value) {// Force immediate scroll without animation when messages changeif (scrollContainer.value) {await nextTick()scrollContainer.value.scrollTop =scrollContainer.value.scrollHeight}}}// Lifecycle hooksonMounted(() => {if (scrollContainer.value) {// Add scroll event listener to track positionscrollContainer.value.addEventListener('scroll',checkScrollPosition)nextTick(() => {scrollToBottom(true) // Initial scroll to bottomtextareaRef.value?.focus() // Focus the input})}})// Cleanup scroll event listeneronUnmounted(() => {if (scrollContainer.value) {scrollContainer.value.removeEventListener('scroll',checkScrollPosition)}})// Check scroll position after any updatesonUpdated(() => {checkScrollPosition()})// Expose necessary functions and statereturn {isAtBottom,showScrollButton,scrollToBottom,textareaRef,pinToBottom,}}
I think that useTemplateRef
is one of the most interesting features added to Vue recently, and you should really give it a try.
It makes dealing with DOM elements so much nicer, giving us more flexibility and a better developer experience.
You can check out the docs here to learn more, and also learn about the advanced typing you can do with it.
If you enjoyed this article and want to know when I publish more (along with some great tips on using Vue), check out my weekly newsletter on all things Vue and Nuxt.