Lifecycle — hook order and where each piece of work belongs
A component has a flow from creation to teardown, and you can insert work along the way: measuring an element's width right after the DOM exists, removing an event listener when it is destroyed, and so on.
Insert it in the wrong place and you reach for an element that does not exist yet and get null. Forget the cleanup and listeners outlive the component. This chapter pins down which hook runs when, as an ordering.
What you'll learn in this chapter
- Explain the constraints on where hooks can be registered
- Know the order hooks run in across parent and child, and choose where to touch the DOM
- Decide where to write cleanup
Where hooks can be registered
The official documentation asks that hooks be called synchronously during setup. When using script setup, though, registration still works after a top-level await.
<script setup lang="ts">
import { onMounted } from 'vue'
onMounted(() => console.log('registered before'))
const res = await fetch('/api/data')
// this is registered too
onMounted(() => console.log('registered after'))
</script>
Because this component has a top-level await, it will not render unless the parent wraps it in Suspense.
Registration works because the compiler saves and restores the component context around the await. script setup is not executed as plain JavaScript; it is rewritten at compile time (chapter 6). That rewriting is what makes registration across an await possible.
What needs care is a hand-written async setup() rather than script setup. That has no restoration machinery, so registration must happen before the first await. The development-build warning is aimed at this case.
The flow up to mount
Put a parent and a child side by side and measure the order that actually runs, and you get this.
parent: setup
parent: onBeforeMount
child: setup
child: onBeforeMount
child: onMounted
parent: onMounted
Here is what that tells you.
setup runs first. Even alongside the Options API, setup comes before beforeCreate (the official documentation states this explicitly). Remember it as "runs first", not "runs partway through initialisation".
onMounted completes from child to parent. By the time the parent's onMounted runs, the child is mounted, so the parent can touch the child's DOM. Conversely, at the parent's onBeforeMount the child does not exist yet.
On the first mount, the ordering and the presence of the DOM line up. On updates they do not, so a later section handles that separately.
When the child has a top-level await, it depends on where you put Suspense. A component of that shape has to be wrapped in Suspense (covered in chapter 13).
With Suspense between parent and child, the child's contents are absent at the parent's onMounted. The parent moves on without waiting for its own mount, and the child appears once it resolves.
<!-- .leaf is absent in the parent's onMounted -->
<div ref="box">
<Suspense><AsyncLeaf /></Suspense>
</div>
With Suspense outside both, the child's DOM is present at the parent's onMounted. The parent joins the side that waits for resolution, so its onMounted runs after resolution.
<!-- .leaf is present in Wrapper's onMounted -->
<Suspense>
<Wrapper><AsyncLeaf /></Wrapper>
</Suspense>
<template>
<div ref="box"><slot /></div>
</template>
Both the form that receives the child through a slot and the form where Wrapper writes AsyncLeaf in its own template were measured, and the result was the same. If you are writing work that touches the child's DOM from the parent, check where Suspense sits.
<script setup lang="ts">
import { onBeforeMount, onMounted, useTemplateRef } from 'vue'
const canvasRef = useTemplateRef('canvas')
onBeforeMount(() => {
// null, because the DOM does not exist yet
console.log(canvasRef.value)
})
onMounted(() => {
// reachable here
const ctx = canvasRef.value?.getContext('2d')
})
</script>
<template>
<canvas ref="canvas" />
</template>
Work such as measuring DOM dimensions, attaching an external library to an element, or setting focus goes in onMounted.
The flow of an update
When state changes and a re-render happens, onBeforeUpdate and onUpdated run.
When the change does not reach the child (you changed only the parent's state and not the child's props), only the parent's hooks run.
parent: onBeforeUpdate
parent: onUpdated
Whether the child updates is determined by whether the child's render reads that value. Change state the parent holds and, if the child does not read it, the child's update hooks do not run. When the change arrives through props, it looks like this.
parent: onBeforeUpdate
child: onBeforeUpdate
child: onUpdated
parent: onUpdated
Here onUpdated completes from child to parent.
This ordering is for the case where the child updates because it received new props from the parent. The child's re-render happens inside the parent's.
When the child reads the same state itself, it is reversed. If parent and child each read a ref or a store placed in a module, the child's re-render is scheduled separately from the parent's, so the parent's onUpdated runs first.
parent: onBeforeUpdate
child: onBeforeUpdate
parent: onUpdated
child: onUpdated
The criterion is "what does the child's render read?" Reading props passed from the parent puts the child's update inside the parent's; the child reading outside state itself puts it in its own bracket. In that arrangement, update hooks run even for a child that received no change through props.
Slot contents belong to this side too. Pass a {{ msg }} written in the parent's template into the child's slot, and that expression is evaluated within the child's render, so it is the child that reads msg. Measured, only the child's update hooks run and the parent's do not.
child: onBeforeUpdate
child: onUpdated
Being written in the parent's template does not mean the parent reads it.
In either ordering, the child's DOM is already updated by the time the parent's onUpdated runs. onUpdated is the kind of hook called once after all re-rendering has been flushed, so even when the hook call order puts the parent first, the DOM rewriting finished before it. Measured, the child's display shows the old value at the parent's onBeforeUpdate and the new one at onUpdated.
Changing state inside onUpdated triggers another update, repeating forever. Keep it to work that reads the DOM. To do something in response to a value changing, use watch from chapter 5. Because what is watched is stated explicitly, you avoid unintended re-runs.
The flow of teardown
parent: onBeforeUnmount
child: onBeforeUnmount
child: onUnmounted
parent: onUnmounted
onBeforeUnmount goes from parent to child, onUnmounted from child to parent. Use onBeforeUnmount when you need to act while the DOM is still there, and onUnmounted when after it has been detached is fine.
The typical cleanup is undoing what you registered yourself.
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
const width = ref(0)
const handleResize = () => {
width.value = window.innerWidth
}
onMounted(() => {
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
</script>
<template>
<p>{{ width }}px</p>
</template>
Write registration and removal as a pair. Once you have several of these, extracting them into a composable, covered in chapter 8, keeps each pair in one place.
Nothing is destroyed while KeepAlive holds the cache
A component wrapped in KeepAlive is not destroyed while it stays in the cache, even after it leaves the screen. Its state is preserved too.
While it remains cached, showing and hiding it runs different hooks. The conditions for leaving the cache are covered in the next section.
| Action | Hooks that run |
|---|---|
| First display | onMounted → onActivated |
| Hiding it | onDeactivated only (onUnmounted does not run) |
| Showing it again | onActivated only (onMounted does not run) |
Write cleanup only in onUnmounted and the listener survives while the component is cached. Put the ResizeWatcher above inside KeepAlive and it keeps reacting to resize and updating width while hidden.
If it might be used under KeepAlive, either remove the listener in onDeactivated as well, or keep it out of the cache in the first place.
What happens when it leaves the cache
The table above applies while it stays cached. Behaviour on the way out differs by route, so here are the three measured cases.
Pushed out by exceeding the max capacity. The one pushed out skips onDeactivated and goes straight to onUnmounted.
<KeepAlive :max="1">
<component :is="current" />
</KeepAlive>
A: onMounted
A: onActivated
A: onUnmounted
B: onMounted
B: onActivated
The one being pushed out has its cache marker removed, so at the point of being hidden it does not enter the "stash into the cache" route and is destroyed outright.
Removing it from include depends on whether it is displayed at the time. One that was hidden and sitting in the cache is destroyed on the spot and onUnmounted runs. One that is displayed merely loses its marker and stays displayed, then is destroyed the next time it is hidden.
Destroying the KeepAlive itself runs onUnmounted after onDeactivated.
A: onDeactivated
A: onUnmounted
To make cleanup reliable, put it in both onDeactivated and onUnmounted. The route where max pushes something out never runs onDeactivated, so onDeactivated alone leaks. Conversely, if you want to release resources while it is off-screen but still cached, onUnmounted alone is too late.
What goes where
| What you want to do | Where it goes |
|---|---|
Computing initial values, registering watch | Directly in script setup |
| Measuring DOM dimensions, initialising an external library, focus | onMounted |
| Reading the DOM after an update | onUpdated |
| Cleanup while the DOM is still present | onBeforeUnmount |
| Removing event listeners and timers | onUnmounted |
| Reacting to a value changing | watch / watchEffect (chapter 5) |
Data fetching can be written directly in script setup or in onMounted. The difference shows when rendering on the server. Directly in script setup it runs before rendering, while onMounted only ever runs in the browser (covered in chapter 19).
Hooks for debugging
To chase down the cause of a re-render, onRenderTracked and onRenderTriggered are available. They tell you which values were recorded as dependencies and which change triggered the re-render.
<script setup lang="ts">
import { onRenderTriggered } from 'vue'
onRenderTriggered((event) => {
// what caused the re-render
console.log(event.key, event.type)
})
</script>
Both are development-mode only and are not included in production builds. They are not called during server-side rendering either.
Summary
- With
script setup, hooks are registered even across a top-levelawait. The compiler saves and restores the context. A hand-writtenasync setup()gets no restoration, so register before the firstawait setupruns first. Even alongside the Options API, it comes beforebeforeCreateonMountedcompletes from child to parent, and the child's DOM is ready by the parent's turn. But when the child has a top-levelawait, it depends on whereSuspensesits. WithSuspensebetween parent and child the contents are absent at the parent'sonMounted; outside both, they are presentonUpdatedcompletes from child to parent when the child updates because it received new props from the parent. When the child reads the same state itself, the parent runs first. The criterion is "why is the child updating?" — props, or the child's own dependency- In either ordering, the child's DOM is already updated by the parent's
onUpdated.onUpdatedis called after all re-rendering has been flushed, so hook order and DOM freshness are separate matters - Whether update hooks run depends on whether that render reads the changed value. When the child reads external state, or receives it as slot content, only the child runs even though no change came through props
- Changing state inside
onUpdatedrepeats the update. To react to a value changing, usewatch onBeforeUnmountgoes parent to child,onUnmountedchild to parent. Use the former when you need the DOM- Write listeners and timers you registered yourself as registration-and-removal pairs
- Nothing is destroyed while
KeepAliveholds the cache.onDeactivatedruns in place ofonUnmounted, so cleanup written only inonUnmountedleaves listeners alive while cached - Behaviour on leaving the cache differs by route. Being pushed out by
maxskipsonDeactivatedand goes toonUnmounted. Removal fromincludegives an immediateonUnmountedif hidden, or defers until it next disappears if displayed. Destroying theKeepAliveitself givesonDeactivated→onUnmounted - Cleanup placed in only one of those hooks leaks on one of the routes above
onRenderTracked/onRenderTriggeredare tools for chasing the cause of a re-render and are development-mode only
- Vue — Lifecycle Hooks — the specification of every hook and the synchronous-registration constraint
- Vue — Lifecycle Diagram — the official lifecycle diagram
- Vue — Reactivity in Depth — how to use the debug hooks
- React guide — useEffect — comparison with a design that handles registration and cleanup of side effects through one API
What to read next
- Composables — extracting registration-and-removal pairs together
- Computed properties and watchers — the machinery for reacting to a value changing