14 — Dynamic & Async Components
component — Runtime Component Switching
DynamicComponent.vue
<script setup>
import { shallowRef, markRaw, ref, computed } from 'vue'
import HomeView from './HomeView.vue'
import ProfileView from './ProfileView.vue'
import SettingsView from './SettingsView.vue'
// ── shallowRef: hold component definitions without deep reactivity ──
// Components are complex objects — reactive() wrapping them is wasteful
// and can break Vue's internal compilation caches. Use shallowRef or markRaw.
const components = shallowRef({
home: markRaw(HomeView),
profile: markRaw(ProfileView),
settings: markRaw(SettingsView),
})
const activeTab = ref('home')
const currentComponent = computed(() => components.value[activeTab.value])
</script>
<template>
<!-- ── :is binds to: component object | string name | async component ── -->
<!-- Vue resolves the component and renders it. -->
<!-- Switching :is unmounts the old component, mounts the new one. -->
<component :is="currentComponent" />
<nav>
<button v-for="(comp, name) in components" :key="name" @click="activeTab = name">
{{ name }}
</button>
</nav>
</template>
Anti-Pattern: reactive() on Component Definitions
anti-pattern.ts
import { reactive } from 'vue'
import HomeView from './HomeView.vue'
// ❌ WRONG: reactive() wraps the component in a deep Proxy
// This breaks Vue's internal component caching and causes subtle render bugs.
const components = reactive({
home: HomeView, // Proxy-wrapped — internal compilation cache misses
})
// ✅ CORRECT: shallowRef or markRaw
import { shallowRef, markRaw } from 'vue'
const components = shallowRef({
home: markRaw(HomeView), // raw — no Proxy wrapping
})
defineAsyncComponent — Code Splitting with States
async-components.ts
import { defineAsyncComponent } from 'vue'
import LoadingSpinner from './LoadingSpinner.vue'
import ErrorDisplay from './ErrorDisplay.vue'
// ── Basic async component: loaded on first use ──
// Dynamic import creates a separate chunk (code splitting).
const AsyncDashboard = defineAsyncComponent(() => import('./Dashboard.vue'))
// ── Full configuration: loading/error states, delay, timeout ──
const AsyncChart = defineAsyncComponent({
// loader: returns a promise that resolves to the component
loader: () => import('./Chart.vue'),
// loadingComponent: shown while the chunk downloads
loadingComponent: LoadingSpinner,
// errorComponent: shown if the chunk fails to load
errorComponent: ErrorDisplay,
// delay: ms before showing loadingComponent (avoid flicker for fast loads)
delay: 200,
// timeout: ms before showing errorComponent (chunk took too long)
timeout: 10_000,
// suspensible: defer to <Suspense> boundary (true by default)
// If false, shows loadingComponent instead of using Suspense fallback
suspensible: true,
// ── onError: retry logic for failed loads ──
onError(error, retry, fail, attempts) {
// error: the load error
// retry(): call to retry loading
// fail(): call to give up (show errorComponent)
// attempts: number of attempts so far
if (error.message.includes('Network') && attempts <= 3) {
setTimeout(retry, 1000 * attempts) // exponential-ish backoff
} else {
fail() // give up — show errorComponent
}
},
})
KeepAlive — LRU Caching of Component Instances
KeepAlivePattern.vue
<script setup>
import { ref, computed, KeepAlive } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
// ── KeepAlive caches component instances when they're toggled out ──
// Instead of destroying + recreating (losing state), the instance is
// detached from the DOM and cached in memory. Re-activation restores state.
//
// Hooks: onActivated (re-attached), onDeactivated (detached, not destroyed)
</script>
<template>
<!-- ── include/exclude: cache only specific components by name ── -->
<!-- Component name comes from: defineOptions({ name: 'X' }) or .vue filename -->
<KeepAlive
:include="['UserList', 'SearchResults']"
:exclude="['HeavyChart']"
:max="5"
>
<!-- ── max: LRU eviction — keep at most 5 instances ── -->
<!-- When 6th is cached, the least recently used is destroyed -->
<component :is="route.meta.component" />
</KeepAlive>
</template>
KeepAliveTab.vue
<script setup>
import { ref, onMounted, onActivated, onDeactivated, onUnmounted, defineOptions } from 'vue'
defineOptions({ name: 'UserList' }) // required for KeepAlive include/exclude
const searchQuery = ref('')
// ── onMounted fires ONCE even with KeepAlive ──
onMounted(() => console.log('mounted — fires once'))
// ── onActivated fires on EVERY activation (including first mount) ──
onActivated(() => {
// ── Resume polling, re-attach event listeners, refresh data ──
// This is the "resume" hook — component is visible again.
startPolling()
})
// ── onDeactivated fires when cached (detached from DOM, NOT destroyed) ──
onDeactivated(() => {
// ── Pause polling, remove event listeners — component is hidden ──
// State (searchQuery, scroll position) is PRESERVED for re-activation.
stopPolling()
})
// ── onUnmounted fires only when KeepAlive evicts (max exceeded) or is removed ──
onUnmounted(() => console.log('truly destroyed'))
</script>
Suspense — Async Component Orchestration
SuspensePattern.vue
<script setup>
import { ref, onErrorCaptured, defineAsyncComponent } from 'vue'
// ── Suspense: coordinates multiple async child components ──
// Shows fallback until ALL async children's setup() promises resolve.
const AsyncHeader = defineAsyncComponent(() => import('./AsyncHeader.vue'))
const AsyncMain = defineAsyncComponent(() => import('./AsyncMain.vue'))
const AsyncFooter = defineAsyncComponent(() => import('./AsyncFooter.vue'))
const error = ref(null)
// ── Error handling: Suspense doesn't handle errors itself ──
// Use onErrorCaptured in the parent to catch async setup errors.
onErrorCaptured((err) => {
error.value = err
return false // prevent error from propagating further
})
</script>
<template>
<div v-if="error" class="error">
<p>Failed to load: {{ error.message }}</p>
<button @click="error = null">Retry</button>
</div>
<!-- ── Suspense shows #fallback until ALL children resolve ── -->
<Suspense v-else>
<template #default>
<AsyncHeader />
<AsyncMain />
<AsyncFooter />
<!-- All three must resolve before #default renders -->
</template>
<template #fallback>
<div class="loading-skeleton">Loading page…</div>
</template>
</Suspense>
</template>
AsyncChild.vue
<script setup>
import { ref } from 'vue'
const data = ref(null)
// ── Top-level await makes this an async component ──
// Suspense waits for this promise before rendering the child.
const res = await fetch('/api/data')
data.value = await res.json()
</script>
<template>
<div>{{ data }}</div>
</template>
💡 Tips & Tricks
tips.ts
// ── 1. markRaw vs shallowRef for component refs ──
// markRaw(comp): marks a single component as non-reactive
// shallowRef({ a: compA, b: compB }): the whole object is shallow (non-deep)
// ── 2. Async component + Suspense + error boundary ──
// Wrap <Suspense> in a parent with onErrorCaptured for full error handling.
// Suspense handles loading, parent handles errors.
// ── 3. KeepAlive include can be a regex ──
// <KeepAlive :include="/^(User|Profile)/"> → caches any component starting with User or Profile
// ── 4. Prefetch async components on hover ──
// <RouterLink @mouseover="() => import('./NextPage.vue')">Next</RouterLink>
// Triggers chunk download before navigation — faster perceived load.
// ── 5. defineAsyncComponent onError for CDN fallback ──
onError(err, retry, fail) {
// Try loading from a backup CDN if the primary fails
if (primaryCDNFailed) { retryWithBackupCDN() } else { fail() }
}
⚠️ Edge Cases & Gotchas
edge-cases.ts
// ── 1. :is with string name requires global registration ──
// <component :is="'MyComponent'" /> — only works if MyComponent is globally
// registered via app.component(). With <script setup>, import the component
// object directly: <component :is="MyComponent" />
// ── 2. KeepAlive + onMounted fires only once ──
// Common bug: event listeners in onMounted with KeepAlive.
// onDeactivated should remove them, onActivated should re-add.
// Putting cleanup in onUnmounted means listeners persist while cached.
// ── 3. Async component timeout doesn't cancel the import ──
// If timeout fires, errorComponent shows, but the import promise still resolves
// later. The component is cached — next render uses it without re-loading.
// ── 4. Suspense fallback shows for ALL async children ──
// If one child takes 5s and another takes 0.1s, the fast one isn't shown
// until the slow one resolves. Split into separate Suspense boundaries if needed.
// ── 5. :max on KeepAlive — eviction is LRU, not FIFO ──
// The least recently ACTIVATED (not created) component is evicted.
// A component created first but used most recently survives over newer ones.
// ── 6. Suspense can't catch errors in event handlers ──
// Only catches errors in: render, setup, lifecycle, watchers of async children.
// Event handler errors go to app.config.errorHandler.
🧠 Spot the Bug
A KeepAlive tab's scroll position resets when the user returns to it.
KeepAliveBug.vue
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
const scrollPos = ref(0)
onMounted(() => {
window.addEventListener('scroll', () => { scrollPos.value = window.scrollY })
})
onUnmounted(() => {
// ⚠️ Doesn't fire on tab switch — only on true destruction (eviction)
window.removeEventListener('scroll', handler)
window.scrollTo(0, scrollPos.value) // ← never runs on tab switch
})
</script>
Answer
With KeepAlive, switching tabs fires onDeactivated (not onUnmounted) and returning fires onActivated (not onMounted). The scroll restoration code in onUnmounted never runs during tab switching — it only runs when the component is truly evicted from cache.
Fix — use onActivated/onDeactivated for KeepAlive lifecycle:
KeepAliveFixed.vue
<script setup>
import { ref, onActivated, onDeactivated } from 'vue'
const scrollPos = ref(0)
function handler() { scrollPos.value = window.scrollY }
onActivated(() => {
// Restore scroll position when tab is re-activated
window.scrollTo(0, scrollPos.value)
window.addEventListener('scroll', handler)
})
onDeactivated(() => {
// Save scroll position when tab is deactivated (cached)
window.removeEventListener('scroll', handler)
})
</script>
The lesson: with KeepAlive, onMounted/onUnmounted fire only once. Use onActivated/onDeactivated for save/restore logic that should run on every tab switch.