Skip to content
TutorialBeginner

Lifecycle hooks and template refs, with useTemplateRef()

Sooner or later a component needs the real DOM: to focus an input, measure a box or start a timer. Vue gives you two tools for that, lifecycle hooks and template refs, and in Vue 3.5 the second one got simpler.

vuejs.school team4 Nov 20255 min readVue 3.5

Lifecycle hooks and template refs are both about timing. Your <script setup> code runs before the component's elements exist, and a timer it starts outlives the component unless you stop it.

The examples below focus a search box as soon as it appears, reach the inputs a v-for creates, and stop a timer when its component goes away.

When onMounted, onUpdated and onUnmounted run

A component does not exist all at once. Vue runs your <script setup> first, when there is no HTML yet. Then it renders the template and inserts the result into the page; that moment is called mounting. Every time reactive data changes, Vue re-renders the parts that depend on it; that is an update. When the component is removed, because a v-if turned false or the user navigated away, it is unmounted.

Lifecycle hooks are functions you call in <script setup> to run code at those moments. The three you will use most are onMounted, onUpdated and onUnmounted. Each takes a function:

components/LifecycleLog.vue
<script setup lang="ts">
import { onMounted, onUnmounted, onUpdated, ref } from 'vue'

const count = ref(0)

onMounted(() => console.log('mounted: the DOM exists now'))
onUpdated(() => console.log('updated: the DOM was re-rendered'))
onUnmounted(() => console.log('unmounted: the DOM is gone'))
</script>

<template>
  <button type="button" @click="count++">Clicked {{ count }} times</button>
</template>

Open the console and click. You see mounted once, updated after every click, and unmounted when the parent removes the component. There are also onBeforeMount, onBeforeUpdate and onBeforeUnmount, which fire just before each step; onBeforeUnmount is the one worth remembering, because the DOM is still there when it runs.

Register hooks while Vue is setting the component up: at the top level of <script setup>, or inside a function that <script setup> calls straight away. Vue attaches each hook to the component it is setting up at that moment. In a setTimeout callback, a click handler, or the part of an async function that runs after an await, there is no such component, so the hook never runs and Vue prints a warning in development.

Why DOM work waits for onMounted

A template ref is how script code gets hold of an element from the template: give the element a ref attribute with a name and pass the same name to useTemplateRef(). It returns a ref whose .value holds the element. Say you want a search box focused as soon as it appears, so you call focus() straight after asking for the element:

components/SearchBox.vueBreaks
<script setup lang="ts">
import { useTemplateRef } from 'vue'

const input = useTemplateRef<HTMLInputElement>('query')

input.value.focus()
</script>

<template>
  <input ref="query" type="search" placeholder="Search lessons">
</template>

In Chrome this throws TypeError: Cannot read properties of null (reading 'focus'). <script setup> runs before the template is rendered, so at that line the <input> does not exist yet and input.value is null. The element only exists after mount, so that is where the call goes:

components/SearchBox.vueWorks
<script setup lang="ts">
import { onMounted, useTemplateRef } from 'vue'

const input = useTemplateRef<HTMLInputElement>('query')

onMounted(() => {
  input.value?.focus()
})
</script>

<template>
  <input ref="query" type="search" placeholder="Search lessons">
</template>

The ?. is optional chaining: it calls focus() only if input.value is not null. Without it, vue-tsc reports 'input.value' is possibly 'null', because the ref's type is HTMLInputElement | null. That type is accurate: the value is null before mount, and it becomes null again if a v-if removes the element later.

Get elements and child components with useTemplateRef()

The string you pass to useTemplateRef() is the only link between the script and the ref attribute, so the variable can be called anything: in SearchBox.vue the attribute is query and the variable is input.

The generic argument, useTemplateRef<HTMLInputElement>('query'), tells TypeScript what kind of element to expect, so input.value?.focus() and input.value?.select() type-check. With version 2.1 or newer of the Vue language tools (the editor extension and vue-tsc), the type is inferred from the element that a static ref="query" sits on, so you can leave the generic out. Keeping it is harmless and makes the intent visible.

Before 3.5 the pattern was a plain ref whose variable name had to match the attribute:

// Vue 3.4 and earlier: the variable name must equal the ref attribute
const query = ref<HTMLInputElement | null>(null)

Both forms work in 3.5. Use useTemplateRef, because the name matching was easy to break during a rename and the new call says what it is.

A ref on a child component gives you the component instance instead of an element. A <script setup> component exposes nothing by default, so the child must opt in: say Dialog.vue calls defineExpose({ open }). The parent puts ref="dialog" on <Dialog>, types the ref as useTemplateRef<InstanceType<typeof Dialog>>('dialog') (the type of a Dialog instance) and calls dialog.value?.open().

Reach for this rarely. Props and events are the normal way to talk to a child; an exposed method is for imperative things such as opening a dialog.

Reach the elements a v-for renders

When ref sits on an element inside v-for, Vue collects all of them into an array after mount. Type it as an array of elements. This checklist adds a row and focuses its new input:

components/ChecklistEditor.vue
<script setup lang="ts">
import { nextTick, ref, useTemplateRef } from 'vue'

const items = ref([{ id: 1, text: 'Read the lesson' }])
const inputs = useTemplateRef<HTMLInputElement[]>('inputs')

async function addItem() {
  const id = Date.now()
  items.value.push({ id, text: '' })
  await nextTick()
  inputs.value?.find(el => el.dataset.id === String(id))?.focus()
}
</script>

<template>
  <ul>
    <li v-for="item in items" :key="item.id">
      <input ref="inputs" v-model="item.text" :data-id="item.id">
    </li>
  </ul>
  <button type="button" @click="addItem">Add item</button>
</template>

Two details make this work:

  • await nextTick() waits until Vue has re-rendered. Pushing into items schedules the new <li> but does not create it immediately, so without the wait the new input is not in the array yet.
  • The lookup goes through a data-id attribute rather than inputs.value.at(-1), because Vue does not guarantee that the ref array is in the same order as the source array.

React to a change with watch, not onUpdated

onUpdated fires after any re-render of the component, for any reason. It is tempting to use it to react to a change, but it does not tell you what changed, and changing reactive state inside it triggers another update, which can loop.

To run code after a specific piece of data changed and the DOM caught up, watch that data with the { flush: 'post' } option, which calls your function after Vue has updated the DOM. Or keep a normal watcher and await nextTick() inside it, the same wait addItem uses above. Keep onUpdated for the rare case where you must inspect the DOM after every render, such as syncing a third-party widget.

Stop timers and listeners in onUnmounted

Anything you start in onMounted that lives outside the component (a timer, a window listener, a subscription) keeps running after the component is gone unless you stop it. A removed component whose setInterval is still ticking is a memory leak, and its callback can throw when it reads a template ref that is now null.

Clock.vue starts a timer and a resize listener on mount and stops both on unmount:

components/Clock.vue
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'

const now = ref(new Date())
const width = ref(window.innerWidth)
let timer: number | undefined

function onResize() {
  width.value = window.innerWidth
}

onMounted(() => {
  timer = window.setInterval(() => { now.value = new Date() }, 1000)
  window.addEventListener('resize', onResize)
})

onUnmounted(() => {
  window.clearInterval(timer)
  window.removeEventListener('resize', onResize)
})
</script>

<template>
  <p>{{ now.toLocaleTimeString() }} at {{ width }}px</p>
</template>

Reading window.innerWidth during setup is fine in a browser-only app: window exists before any component, and only the component's own elements wait for mount.

The pairing is the rule: every setInterval has a clearInterval, every addEventListener has a removeEventListener with the same function reference, which is why onResize is a named function rather than an inline arrow.

Once you have written this pair three times, a library such as VueUse's useEventListener and useIntervalFn will do the cleanup for you, and it is fine to use them. Understanding the pair first is what lets you spot the leak when a library is not involved.

Check it stuck3 questions from the Lifecycle and template refs quiz.
Practice now
Was this helpful?

Keep reading