Skip to main content

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.

components/Panel.vue
<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>
components/Wrapper.vue
<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.

components/Chart.vue
<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.

components/ResizeWatcher.vue
<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.

ActionHooks that run
First displayonMounted → onActivated
Hiding itonDeactivated only (onUnmounted does not run)
Showing it againonActivated 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 doWhere it goes
Computing initial values, registering watchDirectly in script setup
Measuring DOM dimensions, initialising an external library, focusonMounted
Reading the DOM after an updateonUpdated
Cleanup while the DOM is still presentonBeforeUnmount
Removing event listeners and timersonUnmounted
Reacting to a value changingwatch / 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-level await. The compiler saves and restores the context. A hand-written async setup() gets no restoration, so register before the first await
  • setup runs first. Even alongside the Options API, it comes before beforeCreate
  • onMounted completes from child to parent, and the child's DOM is ready by the parent's turn. But when the child has a top-level await, it depends on where Suspense sits. With Suspense between parent and child the contents are absent at the parent's onMounted; outside both, they are present
  • onUpdated completes 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. onUpdated is 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 onUpdated repeats the update. To react to a value changing, use watch
  • onBeforeUnmount goes parent to child, onUnmounted child 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 KeepAlive holds the cache. onDeactivated runs in place of onUnmounted, so cleanup written only in onUnmounted leaves listeners alive while cached
  • Behaviour on leaving the cache differs by route. Being pushed out by max skips onDeactivated and goes to onUnmounted. Removal from include gives an immediate onUnmounted if hidden, or defers until it next disappears if displayed. Destroying the KeepAlive itself gives onDeactivated → onUnmounted
  • Cleanup placed in only one of those hooks leaks on one of the routes above
  • onRenderTracked / onRenderTriggered are tools for chasing the cause of a re-render and are development-mode only
Related references