Understanding when code runs in Vue 3
Lifecycle hooks in Vue 3: a hands-on guide for engineers means knowing exactly when your component initializes, renders, updates, and tears down. Most of us learn Vue's lifecycle hooks early in our careers, but after shipping features in production, we realize we're using them wrong—or at all. Understanding the timing of these hooks is foundational to writing components that don't leak memory, race conditions, or stale data.
In this article, we'll walk through the practical lifecycle hooks you actually need in production code, show you where we've burned time with anti-patterns, and explain when watchers should replace lifecycle logic entirely.
onMounted: accessing the DOM safely
onMounted fires after the component's DOM has been inserted into the document. This is your only safe place to interact with the actual DOM—to measure element dimensions, initialize third-party libraries, or set focus on an input.
In a real production scenario, imagine we're building a dashboard where users can resize a chart container. We need to know the container's width to render an SVG or canvas correctly.
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const chartContainer = ref<HTMLDivElement | null>(null)
const chartWidth = ref(0)
onMounted(() => {
if (chartContainer.value) {
chartWidth.value = chartContainer.value.offsetWidth
}
})
</script>
<template>
<div ref="chartContainer" class="chart">
<p v-if="chartWidth">Chart width: {{ chartWidth }}px</p>
</div>
</template>Notice we're not reading offsetWidth in setup—it would return 0 because the element doesn't exist in the DOM yet. onMounted guarantees the ref has been populated and the element is rendered.
Another common pattern: initializing a third-party library like a charting tool or map SDK:
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { Chart } from 'chart.js'
const canvasRef = ref<HTMLCanvasElement | null>(null)
let chartInstance: Chart | null = null
onMounted(() => {
if (canvasRef.value) {
const ctx = canvasRef.value.getContext('2d')
if (ctx) {
chartInstance = new Chart(ctx, {
type: 'line',
data: { labels: [], datasets: [] }
})
}
}
})
</script>
<template>
<canvas ref="canvasRef"></canvas>
</template>We store the chart instance outside of reactive state because we don't need Vue to track it—we're just keeping a reference so we can clean it up later. This is a subtle but important distinction we'll revisit in the next section.
onUnmounted: cleanup and preventing memory leaks
For every resource you acquire in onMounted, you must release it in onUnmounted. Event listeners, timers, subscriptions, and third-party library instances all need cleanup. Skip this and you'll have memory leaks that aren't obvious until you navigate between pages 50 times in your SPA.
Let's extend our chart example to include proper cleanup:
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { Chart } from 'chart.js'
const canvasRef = ref<HTMLCanvasElement | null>(null)
let chartInstance: Chart | null = null
onMounted(() => {
if (canvasRef.value) {
const ctx = canvasRef.value.getContext('2d')
if (ctx) {
chartInstance = new Chart(ctx, {
type: 'line',
data: { labels: [], datasets: [] }
})
}
}
})
onUnmounted(() => {
if (chartInstance) {
chartInstance.destroy()
chartInstance = null
}
})
</script>
<template>
<canvas ref="canvasRef"></canvas>
</template>The destroy() call here is critical. Chart.js will internally clean up canvas contexts, event listeners, and animation frames. Without this call, creating and destroying the component repeatedly will accumulate memory usage.
Here's a more subtle example: a component that listens to window scroll events to update a "sticky header" state:
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
const isScrolling = ref(false)
const handleScroll = () => {
isScrolling.value = window.scrollY > 100
}
onMounted(() => {
window.addEventListener('scroll', handleScroll)
})
onUnmounted(() => {
window.removeEventListener('scroll', handleScroll)
})
</script>
<template>
<header :class="{ 'is-scrolling': isScrolling }">
Navigation
</header>
</template>If you forget to remove the listener in onUnmounted, every time this component mounts (perhaps in a modal or a route), you add another listener. After 10 modal opens, handleScroll fires 10 times per scroll event. The performance degradation is linear and painful.
onBeforeUpdate and onUpdated: rarely the answer
onBeforeUpdate runs before the component re-renders due to reactive data changes. onUpdated runs after. In our experience across many production codebases, these hooks are needed less than 1% of the time.
The problem: using these hooks usually means you're trying to react to data changes in the wrong way. Your instinct will be:
"When this prop changes, I need to do something" → reach for
onBeforeUpdateoronUpdated
But you should use a watcher instead. We'll cover that in the next section.
The legitimate cases for these hooks are rare. One example: you have a paginated list and you need to scroll to the top when the page changes. You can't do this reliably with onMounted because the component isn't unmounting between pages. So:
<script setup lang="ts">
import { ref, computed, onUpdated } from 'vue'
const page = ref(1)
const items = ref<string[]>([])
const listContainer = ref<HTMLDivElement | null>(null)
const paginatedItems = computed(() => {
const start = (page.value - 1) * 10
return items.value.slice(start, start + 10)
})
onUpdated(() => {
// After the component re-renders with new page data
if (listContainer.value) {
listContainer.value.scrollTop = 0
}
})
const goToPage = (newPage: number) => {
page.value = newPage
}
</script>
<template>
<div ref="listContainer" class="list">
<div v-for="item in paginatedItems" :key="item">
{{ item }}
</div>
</div>
<button @click="goToPage(2)">Next Page</button>
</template>Even here, this is borderline. A more elegant approach would be using a watcher on the page number, or using a scroll-behavior directive. The lesson: exhaust other options before reaching for onBeforeUpdate or onUpdated.
Prefer watchers over lifecycle logic
Watchers let you declare: "When X changes, do Y." This is more declarative, easier to test, and easier to debug than burying logic inside lifecycle hooks.
Let's say we're building a component that fetches user data based on a user ID prop. Many engineers write:
<script setup lang="ts">
import { ref, onMounted } from 'vue'
interface Props {
userId: string
}
const props = defineProps<Props>()
const userData = ref<Record<string, any> | null>(null)
const loading = ref(false)
const fetchUser = async () => {
loading.value = true
try {
const response = await fetch(`/api/users/${props.userId}`)
userData.value = await response.json()
} finally {
loading.value = false
}
}
onMounted(() => {
fetchUser()
})
</script>This works for the initial load. But if the parent component changes the userId prop, nothing happens. You'd have to add another hook or a watcher anyway. Here's the better pattern:
<script setup lang="ts">
import { ref, watch } from 'vue'
interface Props {
userId: string
}
const props = defineProps<Props>()
const userData = ref<Record<string, any> | null>(null)
const loading = ref(false)
const fetchUser = async (id: string) => {
loading.value = true
try {
const response = await fetch(`/api/users/${id}`)
userData.value = await response.json()
} finally {
loading.value = false
}
}
watch(() => props.userId, (newId) => {
fetchUser(newId)
}, { immediate: true })
</script>The immediate: true option runs the watcher on mount, so you don't need onMounted at all. When the prop changes, the watcher fires automatically. This is more predictable and easier to reason about.
Another common pattern: syncing a local state value with a prop. You might reach for onUpdated, but a watcher is cleaner:
<script setup lang="ts">
import { ref, watch } from 'vue'
interface Props {
modelValue: string
}
const props = defineProps<Props>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
const localValue = ref(props.modelValue)
watch(() => props.modelValue, (newValue) => {
localValue.value = newValue
})
const updateValue = (value: string) => {
localValue.value = value
emit('update:modelValue', value)
}
</script>
<template>
<input :value="localValue" @input="updateValue($event.target.value)" />
</template>This handles the case where a parent updates the prop, and we need to sync our local copy. Clean, explicit, testable.
Common mistakes
Forgetting to clean up in onUnmounted
We've all done this once. The fix is simple: establish a habit. Every time you add an onMounted, immediately write the onUnmounted cleanup, even if you think you don't need it. This prevents the "forgot about this" bugs that show up weeks later in QA or production.
Using lifecycle hooks when watchers are cleaner
If your lifecycle hook logic depends on reactive values changing, use a watcher instead. Watchers are more declarative and easier to test in isolation.
Accessing the DOM in setup (or before onMounted)
The reactive system is ready in setup, but the DOM isn't. Refs won't have values yet. Always defer DOM access to onMounted.
Storing all cleanup state in reactive refs
Not everything needs to be reactive. Store third-party library instances, timers, and event listener references in plain variables (or in a non-reactive object). Only track state that affects the template.
Key takeaways
- onMounted is your entry point for DOM access and third-party library initialization. Use template refs to ensure the element exists before you interact with it.
- onUnmounted is non-negotiable for cleanup. Every listener, timer, and library instance must be released to prevent memory leaks.
- onBeforeUpdate and onUpdated are rare in modern Vue code. Usually, a watcher or computed property is a better choice.
- Watchers should be your go-to for reacting to prop or reactive value changes. They're more declarative than embedding logic in lifecycle hooks.
- Keep lifecycle hooks focused and minimal. If they grow complex, consider extracting logic into composables or utility functions.






Comments
Loading comments…