08 — Lifecycle Hooks
The Complete Lifecycle — Execution Order
lifecycle-order.ts
import {
onBeforeMount, onMounted, onBeforeUpdate, onUpdated,
onBeforeUnmount, onUnmounted, onActivated, onDeactivated,
onErrorCaptured, onServerPrefetch, ref
} from 'vue'
// ── Full lifecycle sequence (Composition API names): ────
//
// 1. setup() runs — Composition API entry, before "created"
// 2. onBeforeMount — DOM not yet mounted
// 3. DOM mounted — Vue creates the DOM tree
// 4. onMounted — DOM is accessible, refs are populated
// 5. ── Reactive state changes ──
// 6. onBeforeUpdate — before DOM re-render
// 7. DOM patched — Vue updates the DOM
// 8. onUpdated — after DOM patch
// 9. ── Component unmounts ──
// 10. onBeforeUnmount — cleanup before teardown
// 11. Component destroyed — Vue removes DOM + disposes effects
// 12. onUnmounted — final cleanup, all effects disposed
// ── KeepAlive adds two hooks: ──────────────────────────
// onActivated — component re-activated (inserted from cache)
// onDeactivated — component deactivated (cached, not destroyed)
// ── Error handling: ────────────────────────────────────
// onErrorCaptured — catches errors from descendant components
// (render, lifecycle hooks, watchers, setup of descendants)
// ── SSR-only: ──────────────────────────────────────────
// onServerPrefetch — runs on server during SSR, resolved before render
// ── Every hook is registration-based, not override-based: ──
// You can call onMounted() multiple times — ALL registered callbacks fire in order.
// (Unlike Options API's mounted() which is a single method.)
onMounted(() => console.log('mounted callback 1'))
onMounted(() => console.log('mounted callback 2'))
// Both fire, in registration order.
onMounted — DOM Measurement and Third-Party Init
ChartInit.vue
<script setup>
import { ref, onMounted, onBeforeUnmount, shallowRef } from 'vue'
import Chart from 'chart.js/auto'
const canvasRef = ref(null)
// ── shallowRef: holds the Chart instance without deep reactivity ──
// Chart.js instances are complex objects — reactive() wrapping breaks them.
const chart = shallowRef(null)
onMounted(() => {
// ── DOM is ready: canvasRef.value is now the <canvas> element ──
// Before onMounted, canvasRef.value is null (DOM not created yet).
chart.value = new Chart(canvasRef.value, {
type: 'bar',
data: { labels: ['A', 'B'], datasets: [{ data: [1, 2] }] },
})
})
onBeforeUnmount(() => {
// ── Cleanup: destroy chart instance to free canvas memory ──
// onBeforeUnmount runs while the component is still fully functional
// (refs, state intact). onUnmounted runs after teardown.
chart.value?.destroy()
})
</script>
<template>
<canvas ref="canvasRef" />
</template>
onBeforeUpdate / onUpdated — Avoiding Infinite Loops
UpdateLoopTrap.vue
<script setup>
import { ref, onUpdated, onBeforeUpdate } from 'vue'
const items = ref([])
// ── onBeforeUpdate: read DOM BEFORE the patch ──────────
// Useful for saving scroll position or measuring before changes.
onBeforeUpdate(() => {
// DOM still shows the OLD state — measure before Vue patches it
scrollPosition.value = listEl.value?.scrollTop
})
// ❌ WRONG: mutating state in onUpdated causes infinite loop
onUpdated(() => {
// This fires AFTER every DOM patch. If you mutate reactive state here,
// Vue re-renders → fires onUpdated again → infinite loop.
items.value.push(Date.now()) // DON'T DO THIS
})
// ✅ CORRECT: onUpdated is for DOM reads, not state writes
onUpdated(() => {
// Read-only DOM operations are safe:
if (listEl.value) {
const isScrolledToBottom =
listEl.value.scrollTop + listEl.value.clientHeight >= listEl.value.scrollHeight
if (isScrolledToBottom) emit('scroll-bottom')
}
})
</script>
onUnmounted — Resource Cleanup Checklist
cleanup.ts
import { onUnmounted, onScopeDispose, ref } from 'vue'
// ── Everything registered externally must be cleaned up ──
// Rule: if you called `add`, you must call `remove` on unmount.
onUnmounted(() => {
// ── 1. Event listeners ──
window.removeEventListener('resize', resizeHandler)
document.removeEventListener('keydown', keyHandler)
// ── 2. Timers ──
clearInterval(pollInterval)
clearTimeout(debounceTimer)
// ── 3. WebSockets / EventSource ──
socket.close()
eventSource.close()
// ── 4. Observers ──
intersectionObserver.disconnect()
resizeObserver.disconnect()
// ── 5. Third-party instances ──
chartInstance.destroy()
editorInstance.dispose()
// ── 6. Animations ──
cancelAnimationFrame(rafId)
// ── 7. AbortController (cancel pending fetches) ──
abortController.abort()
})
// ── Preferred: use onScopeDispose inside composables ──
// Composables register their own cleanup, parent doesn't need to know details.
// If the composable uses onScopeDispose, onUnmounted is redundant for that resource.
KeepAlive — onActivated / onDeactivated
KeepAliveTabs.vue
<script setup>
import { ref, onMounted, onActivated, onDeactivated, onUnmounted } from 'vue'
// ── KeepAlive lifecycle: ────────────────────────────────
// First mount: onMounted → onActivated
// Tab switch away: onDeactivated (component stays in memory, DOM detached)
// Tab switch back: onActivated (component re-attached, state preserved)
// KeepAlive removed entirely: onDeactivated → onUnmounted
// ── onMounted fires ONCE even with KeepAlive ──
onMounted(() => console.log('mounted — fires once'))
// ── onActivated fires on EVERY activation (including first mount) ──
onActivated(() => {
// ── Re-start polling, re-subscribe to events, resume video ──
// This is where you "resume" after being cached.
startPolling()
window.addEventListener('visibilitychange', onVisible)
})
// ── onDeactivated fires when cached (NOT destroyed) ──
onDeactivated(() => {
// ── Pause polling, unsubscribe, pause video ──
// Component is detached from DOM but state is preserved.
stopPolling()
window.removeEventListener('visibilitychange', onVisible)
})
// ── onUnmounted fires ONLY when KeepAlive is removed or max exceeded ──
onUnmounted(() => {
// Final cleanup — component is truly destroyed.
// Fires after onDeactivated if the component is evicted from cache.
})
</script>
KeepAliveConfig.vue
<template>
<!-- ── KeepAlive props ── -->
<KeepAlive
:include="['UserList', 'Settings']" <!-- only cache components matching name -->
:exclude="['HeavyChart']" <!-- never cache these -->
:max="5" <!-- LRU — keep max 5 instances -->
>
<component :is="activeComponent" />
</KeepAlive>
<!-- ── Component name matters: include/exclude match component `name` ── -->
<!-- In <script setup>, set name via: defineOptions({ name: 'UserList' }) -->
</template>
onErrorCaptured — Error Boundary Pattern
ErrorBoundary.vue
<script setup>
import { ref, onErrorCaptured } from 'vue'
const error = ref(null)
// ── onErrorCaptured: catches errors from ALL descendant components ──
// Fires for errors in: descendant render, lifecycle hooks, watchers, setup
// (e) error, (instance) component instance, (info) lifecycle hook name
onErrorCaptured((err, instance, info) => {
console.error(`Error in ${instance?.$options?.name}:`, err, 'during', info)
error.value = err
// ── Return value controls propagation: ──
return false // stop propagation — error does not reach parent's onErrorCaptured
// return true (or undefined) — propagate to parent's error handler
// ── This makes it an error boundary: catches and handles, doesn't rethrow ──
})
</script>
<template>
<div v-if="error" class="error-boundary">
<h2>Something went wrong</h2>
<p>{{ error.message }}</p>
<button @click="error = null">Retry</button>
</div>
<slot v-else /> <!-- child components render here -->
</template>
onServerPrefetch — SSR Data Preloading
server-prefetch.ts
import { ref, onServerPrefetch } from 'vue'
const data = ref(null)
// ── onServerPrefetch: runs ONLY on the server during SSR ──
// The callback returns a Promise — the server waits for it to resolve
// before rendering the component's HTML.
// On the client (hydration), this hook is SKIPPED — client uses the
// server-provided state instead of re-fetching.
onServerPrefetch(async () => {
const res = await fetch('/api/initial-data')
data.value = await res.json()
// Server renders with data already populated → no client-side loading flash.
})
// ── When to use: ────────────────────────────────────────
// Critical above-the-fold data that must be in the initial HTML (SEO, perceived perf).
// For non-critical data, prefer client-side fetch with useFetch() composable.
💡 Tips & Tricks
tips.ts
// ── 1. Multiple onMounted calls — all fire in registration order ──
onMounted(() => console.log('first')) // fires first
onMounted(() => console.log('second')) // fires second
// Unlike Options API's single mounted() method.
// ── 2. Hooks must be called synchronously in setup() ──
// ❌ onMounted(() => ...) inside setTimeout — registration too late, won't fire
// ✅ Always register hooks at the top level of setup() or <script setup>
// ── 3. onBeforeUnmount vs onUnmounted ──
// onBeforeUnmount: component still fully functional (refs, state intact)
// → use for cleanup that needs to READ component state
// onUnmounted: effects already disposed, refs may be null
// → use for final logging, pure side-effects
// ── 4. Template refs are null before onMounted ──
// ref(null) in setup → populated during mount → accessible in onMounted
// Guard: if (ref.value) { ... } in case of v-if conditional rendering
⚠️ Edge Cases & Gotchas
edge-cases.ts
// ── 1. onUpdated fires after EVERY re-render, including child re-renders ──
// If a child component updates, the parent's onUpdated fires too.
// Don't use onUpdated for logic that should only run on specific state changes.
// ── 2. onMounted fires before child components are fully mounted ──
// Parent's onMounted fires BEFORE child's onMounted (inside-out for mounted).
// If parent needs child to be mounted, use nextTick() or a child-emitted event.
// ── 3. KeepAlive + onMounted: onMounted fires ONCE, onActivated fires every time ──
// Common bug: putting event listeners in onMounted with KeepAlive.
// Component is deactivated (listener stays) → activated (no re-registration) → listener works.
// But if listener is removed in onUnmounted (not onDeactivated), it's never removed while cached.
// ── 4. Hooks registered in async callbacks don't work ──
// ❌ onMounted(() => setTimeout(() => onUpdated(...), 0)) — onUpdated is too late
// Hooks must be registered during the synchronous setup() execution.
// ── 5. onErrorCaptured doesn't catch errors in event handlers ──
// Only catches errors in: render, setup, lifecycle hooks, watchers.
// Event handler errors go to app.config.errorHandler, not onErrorCaptured.
// ── 6. onServerPrefetch is server-only — skipped on client ──
// Don't put client-side logic in onServerPrefetch — it won't run during hydration.
🧠 Spot the Bug
A KeepAlive component's event listener persists even after the user navigates away.
KeepAliveBug.vue
<script setup>
import { onMounted, onUnmounted, ref } from 'vue'
const position = ref(0)
onMounted(() => {
window.addEventListener('scroll', (e) => {
position.value = window.scrollY
})
})
onUnmounted(() => {
// ⚠️ This only fires when KeepAlive evicts the component (max exceeded or removed)
// NOT when the user navigates to another tab (onDeactivated fires instead)
window.removeEventListener('scroll', handler)
})
</script>
Answer
With KeepAlive, navigating away fires onDeactivated, not onUnmounted. The listener stays active while the component is cached, continuing to fire scroll handlers in the background — wasted CPU and potential state bugs on reactivation.
Fix — use onActivated/onDeactivated instead of onMounted/onUnmounted:
KeepAliveFixed.vue
<script setup>
import { onActivated, onDeactivated, ref } from 'vue'
const position = ref(0)
function handler() { position.value = window.scrollY }
onActivated(() => {
window.addEventListener('scroll', handler) // re-register on every activation
})
onDeactivated(() => {
window.removeEventListener('scroll', handler) // clean up when cached
})
</script>
The lesson: with KeepAlive, onMounted/onUnmounted fire only once (initial mount and final eviction). Use onActivated/onDeactivated for resources that should be active only while the component is visible.