Lifecycle and template refs
Run code when a component appears or leaves with onMounted() and onUnmounted(), reach real elements with useTemplateRef(), and wait for the page with nextTick().
What you'll learn
When you finish, you can do these.- 1Run code right after a component appears, with onMounted()
- 2Stop a timer with onUnmounted() when its component leaves
- 3Reach a real element, like an input, with useTemplateRef()
- 4Wait for the page to update with await nextTick()
Picture a page with a clock on it, built as its own component. When the clock appears, it starts a timer: browser code that moves the time on every second. Hide the clock and the timer keeps running, updating a time nobody can see, unless something stops it. Other code needs the real page: you cannot put the cursor in an input before the input exists. Lifecycle hooks let you run such code at the right moment, as a component appears, changes and leaves.
The Code tab holds that page: App.vue shows an input, a Hide clock button and a list of notes, and Clock.vue is the clock. By the end you can say when each line runs, and make the clock listen for keys only while it is shown. You need two ideas from earlier lessons. From the Reactivity basics lesson: a ref, a box that holds a value and tells Vue when it changes. From the Directives lesson: v-if adds an element while its condition is true and removes it when it turns false.
A component's life, in order
Every component goes through the same steps, in order. Vue runs <script setup> first, before the elements exist. Then it puts the elements on the page, which is called mounting. After that the component re-renders, redrawing its elements, each time a value it shows changes. One day it is removed, for example when its v-if turns false: that is unmounting.
In the diagram, DOM is the usual name for a page's elements.
setup()script setup runs, no DOM yetonBeforeMountonMountedthe DOM exists, template refs are setonUpdatedafter each re-renderonUnmountedclean up timers and listeners
Each step has a hook: a function from Vue that takes a function of yours and calls it at that step.
onMounted()runs once, right after the component's elements are on the page.onUpdated()runs after each re-render. You will rarely need it.onBeforeUnmount()runs right before removal, while the elements still exist.onUnmounted()runs after the elements are gone.
A parent's onMounted() runs after its children's, because Vue mounts everything inside the parent first. App.vue is the clock's parent: it uses Clock.vue inside its template, so when App's onMounted() runs, the clock is already on the page.
Start in onMounted, stop in onUnmounted
Code you start when a component appears must stop when it leaves. Clock.vue starts its timer in onMounted() and stops it in onUnmounted():
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { ticks } from './ticks'
const time = ref(new Date().toLocaleTimeString())
let timer: number | undefined
onMounted(() => {
timer = window.setInterval(() => {
time.value = new Date().toLocaleTimeString()
ticks.value++
}, 1000)
})
onUnmounted(() => {
clearInterval(timer)
})
</script>
setInterval, a browser function, runs code on repeat, here once a second: each run puts the current time in time. It returns a number that names the timer, kept in timer, and clearInterval(timer) stops that timer.
App shows the Clock under a v-if, and its Hide clock button turns it off and on. When v-if removes the Clock, Vue takes its elements off the page, but the timer belongs to the browser, so only the call in onUnmounted() stops it. The same goes for a listener, a function the browser runs on every key press or click: whatever you start, stop.
Each run also adds one to ticks, a ref in a third file, ticks.ts, which App imports too. So App's Ticks: line shows the count even after the Clock is gone. Click Hide clock in the preview and the number stops.
Template refs reach the real element
To put the cursor in App's input when the page opens, your code needs the real input element. Give it a ref attribute in the template and read it in script with useTemplateRef() and the same name. The result is a template ref: a ref whose value is the element. In App.vue, onMounted() calls the input's focus() to do that:
<script setup lang="ts">
const nameInput = useTemplateRef('nameInput')
onMounted(() => {
nameInput.value?.focus()
})
</script>
<template>
<input ref="nameInput" placeholder="Focused on load" />
</template>
When the page opens, the cursor is already in the input.
Until the component mounts, the element does not exist, so the value is null, which stands for nothing there. That is why focus() is called in onMounted(). The ?. skips the call while the value is null, which it is again once the component is removed.
A ref inside v-for collects every element. In App's list of notes, each <li> carries ref="rows", so rows.value, from useTemplateRef('rows'), is an array of the <li> elements, one per note, not always in the list's order.
A template ref shows the page as it is now. Right after you change a value, the page has not caught up: Vue applies all the changes together a moment after your code finishes, on what Vue calls the next tick.
nextTick() returns a promise, a value that stands for work still in progress. Inside a function marked async, await nextTick() makes the lines after it wait until that update is done. App's Add note button runs one such function, addNote: it adds a note, then reports how many <li> rows there were before and after the wait.
async function addNote() {
notes.value.push(`Note ${notes.value.length + 1}`)
const before = rows.value?.length
await nextTick()
report.value = `Rows before nextTick: ${before}. After: ${rows.value?.length}.`
}
Click Add note and the report reads Rows before nextTick: 2. After: 3. The new <li> exists only after the await.
What goes wrong
The first mistake is leaving out the cleanup: the timer outlives the component.
onMounted(() => {
timer = window.setInterval(() => {
time.value = new Date().toLocaleTimeString()
ticks.value++
}, 1000)
})
// no onUnmounted
Hide the clock and Ticks keeps rising. Show it again and Ticks rises by two each second: a second timer started, and the first one never stopped. In a real app this is a memory leak: memory and work held by timers nobody will ever stop. The fix is the missing hook:
onUnmounted(() => {
clearInterval(timer)
})
With it, Hide clock stops the count.
The second mistake is reaching for the element in <script setup> itself, outside any hook, where it does not exist yet.
const nameInput = useTemplateRef('nameInput')
nameInput.value.focus()
The preview stays blank and shows Cannot read properties of null (reading 'focus'). The error stops App from mounting at all. Move the call into onMounted():
const nameInput = useTemplateRef('nameInput')
onMounted(() => {
nameInput.value?.focus()
})
The page loads again, with the cursor in the input.
What to remember
<script setup>runs before the elements exist.onMounted()is the first moment you can touch them, and a parent's runs after its children's.- Whatever you start in
onMounted(), such as a timer or a listener, stop inonUnmounted(). Vue removes elements, not your timers. useTemplateRef('name')reads the element withref="name". It isnullbefore mounting and after unmounting, and an array insidev-for.- After changing a value,
await nextTick()before you read or measure the updated page.
Try it
Open the Code tab and change the starter code. Each task says what you should see.- In
Clock.vue, addconst lastKey = ref('none')andfunction onKey(event: KeyboardEvent) { lastKey.value = event.key }. Callwindow.addEventListener('keydown', onKey)inonMounted()andwindow.removeEventListener('keydown', onKey)inonUnmounted(), and show<p>Last key: {{ lastKey }}</p>. Click the preview and press a few keys: the line shows each one. - In App, add
const wide = ref(false)andconst width = ref(0), put:style="{ width: wide ? '300px' : '' }"on the input, and show<p>Input width: {{ width }}px</p>. Add anasync function widen()that runswide.value = true,await nextTick(), thenwidth.value = nameInput.value?.offsetWidth ?? 0, and a Wider button with@click="widen". Click it: the line shows the new, larger width. Measure before theawaitinstead and it shows the old width: the input has not grown yet. - In
Clock.vue, delete the wholeonUnmountedblock. Click Hide clock: the time disappears, but Ticks keeps rising. Show the clock and it rises by two each second. Put the block back, and after a Hide the number stops.