Vue.js: Dynamic & Async Components
Last updated: 2026-10-20
Dynamic components let you render different components from a single <component> tag—switching tabs, modals, and route views based on data. Async components let you load components on demand—shipping only the code the first screen needs, which improves performance.
Vue 3's experimental <Suspense> component is purpose-built for the loading state of async components, so you can stop hand-writing v-if="loading". This lesson covers these 3 core APIs.
1. What You'll Learn
<component :is="...">dynamic component basics- The 2 forms of
:is: string / component object - Async loading with
defineAsyncComponent - The performance benefits of code splitting (dynamic import)
- Handling async components with
<Suspense> - Dynamic components + keep-alive caching
- 5 real-world scenarios and 3 anti-patterns
2. The 5-v-if Nightmare of Tab Switching
(1) The Pain: 5 Tabs, 10 Nested v-ifs
Alice built an admin dashboard with tab switching:
Product manager Charlie:
"Alice, we're adding 5 more tabs. 10 lines of v-if-else is completely unmaintainable. Use dynamic components."
(2) The Vue Dynamic Component Fix: One <component> for 5 Tabs
<!-- ✅ Correct version: a single <component> switches them -->
<template>
<div class="tabs">
<button
v-for="tab in tabs"
:key="tab.name"
:class="{ active: currentTab === tab.name }"
@click="currentTab = tab.name"
>
{{ tab.label }}
</button>
<!-- ✅ One <component> tag switches dynamically -->
<component :is="currentTabComponent" />
</div>
</template>
<script setup>
const { ref, computed, defineAsyncComponent } = Vue
// Synchronous components
import HomeTab from './tabs/HomeTab.vue'
import ProfileTab from './tabs/ProfileTab.vue'
// Async component (loaded on demand)
const SettingsTab = defineAsyncComponent(() => import('./tabs/SettingsTab.vue'))
const tabs = [
{ name: 'home', label: 'Home' },
{ name: 'profile', label: 'Profile' },
{ name: 'settings', label: 'Settings' }
]
const currentTab = ref('home')
// Computed: decide which component to render based on currentTab
const currentTabComponent = computed(() => {
return { home: HomeTab, profile: ProfileTab, settings: SettingsTab }[currentTab.value]
})
</script>
(3) The Payoff
After switching to dynamic components:
- Lines of code: 10 lines of v-if → 1
<component>line (-90%) - Extensibility: a new tab needs only 1 import + 1 entry in the object
- First-screen performance: SettingsTab loads asynchronously, saving ~50KB
- Maintainability: all 5 tabs are managed in one array
3. <component :is="..."> Basics
(1) 5 Ways to Write :is
<!-- Form 1: component name string (globally registered) -->
<component :is="'HomeTab'" />
<!-- Form 2: component object (most common) -->
<component :is="HomeTab" />
<!-- Form 3: dynamic computed property -->
<component :is="currentTabComponent" />
<!-- Form 4: async component object -->
<component :is="asyncComponent" />
<!-- Form 5: inline component object -->
<component :is="{ template: '<div>Inline</div>' }" />
(2) Complete Example
<!-- TabContainer.vue -->
<template>
<div>
<button
v-for="tab in tabs"
:key="tab.name"
@click="currentTab = tab.name"
>
{{ tab.label }}
</button>
<!-- Dynamic component -->
<component :is="currentTabComponent" />
</div>
</template>
<script setup>
const { ref, computed } = Vue
import HomeTab from './tabs/HomeTab.vue'
import ProfileTab from './tabs/ProfileTab.vue'
import SettingsTab from './tabs/SettingsTab.vue'
const tabs = [
{ name: 'home', label: 'Home' },
{ name: 'profile', label: 'Profile' },
{ name: 'settings', label: 'Settings' }
]
const currentTab = ref('home')
const currentTabComponent = computed(() => ({
home: HomeTab,
profile: ProfileTab,
settings: SettingsTab
}[currentTab.value]))
</script>
(3) 5 Common Use Cases
| Use case | Dynamic component |
|---|---|
| Tab switching | ✅ |
| Modal/Dialog | ✅ |
| Multi-step forms | ✅ |
| Route views | ✅ (<router-view> uses component internally) |
| Theme switching (different layouts) | ✅ |
▶ Example: One <component :is> switching 3 panels
▶ Try it Yourself
Output:
Initially the "Home" button is highlighted green with white text, and the dashed box below reads "Home: 3 todos today, 5 unread messages".
Click "Profile" → the highlight moves, and the whole panel is replaced by "Profile: Alice / admin / signed in for 12 days".
Click "Settings" → it swaps again; all three panels share one `<component>` slot, so the old component is destroyed and the new one is created on every switch.
4. Async Components with defineAsyncComponent
(1) Why Async Components?
A normal component loads all of its code immediately at import time. Large components (rich text editors, chart libraries) aren't needed for the first screen but still block loading. Async components load on demand, making the first screen faster.
<script setup>
const { defineAsyncComponent } = Vue
import LoadingSpinner from './LoadingSpinner.vue'
// Async component: ChartEditor's code loads only when it's used
const ChartEditor = defineAsyncComponent({
// 1. Loader function
loader: () => import('./ChartEditor.vue'),
// 2. Shown while loading
loadingComponent: LoadingSpinner,
// 3. Shown if loading fails
errorComponent: ErrorMessage, // requires import ErrorMessage from './ErrorMessage.vue'
// 4. Load delay (avoids flicker)
delay: 200
})
</script>
<template>
<ChartEditor v-if="showEditor" />
</template>
(2) Shorthand
<script setup>
const { defineAsyncComponent } = Vue
// ✅ Shorthand: loader only
const ChartEditor = defineAsyncComponent(() => import('./ChartEditor.vue'))
</script>
(3) 5 Common Async Component Scenarios
| Scenario | Async load |
|---|---|
| Rich text editor | ✅ |
| Chart libraries (ECharts/D3) | ✅ |
| Large forms (100+ fields) | ✅ |
| Modal dialogs | ✅ |
| Lazy-loaded routes | ✅ (Vue Router does this automatically) |
5. Handling Async Components with <Suspense>
(1) What Is Suspense?
Suspense is Vue 3's loading coordinator for async components—it handles the loading and error states of async components for you. No more hand-written v-if="loading".
(2) 2 Slots
(3) 5 Advantages
| Advantage | Description |
|---|---|
| Concise | No hand-written loading state |
| Unified | Multiple async components handled together |
| Nestable | Nested Suspense is supported |
| Error handling | Works with onErrorCaptured |
| SSR friendly | The server waits automatically |
6. Dynamic Components + keep-alive Caching
(1) Default Behavior: Switching Destroys and Recreates
(2) keep-alive Caching (Avoid Recreating)
(3) keep-alive's 3 Config Options
▶ Example: Dynamic tabs + keep-alive caching
▶ Expand runnable example (69 lines)
Output:
Three tab buttons render, with the active one highlighted in green.
The panel below starts on the Home tab and shows a counter button plus a grey hint.
Clicking the counter to 3, switching to Profile, and coming back to Home still shows Count: 3, while the console logs mounted once followed by activated / deactivated instead of destroy events.
[Home] [Profile] [Settings] ← active tab highlighted in green
┌──────────────────────────────────────┐
│ Home │
│ [Count: 3] │
│ Count is kept after a switch (cache) │
└──────────────────────────────────────┘
console:
🏠 Home mounted
🏠 Home activated
🏠 Home deactivated ← switch to Profile
👤 Profile mounted
👤 Profile activated
👤 Profile deactivated ← switch back to Home
🏠 Home activated ← no second "mounted"
7. Putting It Together: Async Loading with Suspense
▶ Example: Handling async loading with Suspense
▶ Try it YourselfdefineAsyncComponent(() => import('./Chart.vue')). Here async setup() is used to simulate the delay.
Output:
The page shows a button labelled Load async component.
Clicking it renders the amber ⏳ Loading...
fallback for roughly 1.5s, then replaces it with a green panel containing AsyncHeavy and Loaded at: <current time>.
Clicking again unmounts the component.
[Load async component]
0.0s – 1.5s ⏳ Loading... (amber fallback, #fef3c7)
after 1.5s AsyncHeavy
Loaded at: 10:24:31 (green panel, #ecfdf5)
button label becomes [Unload async component]
click it → the panel disappears
(1) 📊 5 forms of :is, compared
| Form | Example | Best for |
|---|---|---|
| String | <component :is="'HomeTab'"> |
Global registration (not recommended) |
| Component object | <component :is="HomeTab"> |
Static import (most common) |
| Async component object | <component :is="AsyncComponent"> |
On-demand loading |
| Computed property | <component :is="currentTabComponent"> |
Dynamic switching |
| Inline object | <component :is="{ template: '...' }"> |
Simple cases |
(2) 📊 5 Common Mistakes at a Glance
| Mistake | Symptom | Fix |
|---|---|---|
| :is is a string but not globally registered | Unknown component warning | Use a component object or register it first |
| Async component without a fallback | Blank screen while loading | Wrap it in Suspense |
| Component switching reloads data | Poor performance | Cache with keep-alive |
| Async component errors unhandled | Silent failure | Pair with onErrorCaptured |
| Dynamic component without :key | State gets mixed up | Add :key="tab.name" |
(3) 📊 5 Performance Comparisons
| Pattern | First screen | Switching | Best for |
|---|---|---|---|
| Direct import | Slow | Reloads | Small components |
| Dynamic import | Fast | Slow on first switch | Large components |
| Suspense | Fast | Smooth | UX-first |
| keep-alive | Fast | 0 reloads | Frequent switching |
| Async + keep-alive | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | Best practice |
❓ FAQ
<component> tag that switches via :is—much more concise. Async components can additionally load on demand.defineAsyncComponent(() => import('...')) must be called at the top level of <script setup> (not inside a function).<component :is="Comp" :prop1="x" :prop2="y" />.errorComponent option plus onErrorCaptured: { loader, errorComponent: ErrorComp }. Inside Suspense you can wrap with onErrorCaptured.defineAsyncComponent internally. They're the same thing.📖 Summary
- Dynamic components:
<component :is="...">switches multiple components with one tag - 5 forms of :is: string / component object / async / computed / inline
- Async components with
defineAsyncComponent: load on demand, fast first screen - Suspense: handles the loading state of async components automatically
- keep-alive + dynamic components: caching avoids repeated creation
- 5 scenarios: tabs / modals / routes / multi-step forms / themes
- Performance best practice: async + keep-alive + Suspense
📝 Exercises
-
Basic (difficulty ⭐) Build a simple tab switcher:
- 3 tabs (Home/Profile/Settings)
- Switch dynamically with
<component :is> - Log "Component X created/destroyed" on every switch
-
Intermediate (difficulty ⭐⭐) Implement tabs + async loading:
- 3 tabs, with SettingsTab loaded via
defineAsyncComponent - Show a loading state (hand-written
v-if="loading") - Switching back to HomeTab must not reload it
- 3 tabs, with SettingsTab loaded via
-
Challenge (difficulty ⭐⭐⭐) Build a complete dynamic + async + caching system:
- 5 tabs, at least 2 loaded asynchronously
- Wrap them in
<Suspense> - Cache with
<keep-alive :max="3"> - Log onActivated/onDeactivated on every switch
- Show an ErrorComponent when an async component fails
- Measure first-screen load time (before/after async)
- Strongly typed dynamic components in TypeScript