Dynamic components in Vue.js
Dynamic components let you swap different Vue components in and out of the same DOM location by changing what the <component :is> attribute points to.
Theory
TL;DR
- Think of it like a TV remote:
<component>is the screen,:isis the button that switches channels. - When
:ischanges, Vue unmounts the old component and mounts the new one at the same spot. - Pass a component object to
:is, not a string. A string fails quietly with no error. - Wrap in
<KeepAlive>to preserve state when switching back. - Use
shallowRefto store the active component reference, notref.
Quick example
<script setup>
import { shallowRef } from 'vue'
import Home from './Home.vue'
import Settings from './Settings.vue'
// shallowRef watches only the reference, not the component object internals
const activeTab = shallowRef(Home)
const tabs = { home: Home, settings: Settings }
</script>
<template>
<button @click="activeTab = tabs.home">Home</button>
<button @click="activeTab = tabs.settings">Settings</button>
<!-- Vue unmounts Home and mounts Settings when activeTab changes -->
<component :is="activeTab" />
</template>Click "Settings" and Vue destroys the Home instance and creates a fresh Settings one in the same spot.
Key difference
Dynamic components solve a different problem than v-if. With v-if, you write one branch per component and the template grows with every new option. With <component :is>, you add a new entry to an object and the template stays the same. Past three components, v-if chains become hard to read. That's when <component :is> earns its place.
When to use
- Tab interfaces: switch between views without a page reload
- Multi-step forms: each step is a separate component
- Plugin systems: load components based on configuration or data
- Admin dashboards: render different panel types (chart, table, form) by data type
- CMS blocks: hero, gallery, testimonial rendered from a config array
For one or two branches, v-if is simpler and more explicit. No need to reach for dynamic components there.
How it works internally
When :is changes, Vue's reactivity system picks up the new value and triggers a component update. It calls beforeUnmount and unmounted on the old component, removes its DOM nodes, then mounts the new component with beforeMount and mounted. Wrap <component> in <KeepAlive> and Vue caches the old instance instead of destroying it. Switching back is instant and all state (form inputs, API results) is preserved.
Common mistakes
Passing a string instead of a component object
<!-- WRONG: Vue looks for 'HomeView' in the global registry. Nothing renders. No error. -->
<script setup>
import { ref } from 'vue'
const currentView = ref('HomeView')
</script>
<template>
<component :is="currentView" />
</template><!-- RIGHT: pass the actual component object -->
<script setup>
import { shallowRef } from 'vue'
import HomeView from './HomeView.vue'
import SettingsView from './SettingsView.vue'
const views = { home: HomeView, settings: SettingsView }
const currentView = shallowRef(HomeView)
</script>
<template>
<component :is="currentView" />
</template>This one is quiet. No warning, nothing renders. It can take 20 minutes to spot.
Forgetting that state is lost without KeepAlive
<!-- WRONG: user fills a form, switches tabs, comes back and the form is empty -->
<component :is="currentTab" />
<!-- RIGHT: form data survives tab switches -->
<KeepAlive>
<component :is="currentTab" />
</KeepAlive>Every time :is changes, Vue destroys the old instance. Inputs, scroll position, loaded API data - all gone. Users notice this immediately and it feels like a bug.
Leaving timers running in cached components
<!-- WRONG: timer keeps running even when the tab is hidden -->
<script setup>
import { onMounted } from 'vue'
onMounted(() => {
setInterval(() => fetch('/api/updates'), 5000)
})
</script><!-- RIGHT: pause when hidden, resume when visible -->
<script setup>
import { onMounted, onActivated, onDeactivated } from 'vue'
let timerId
onMounted(() => {
timerId = setInterval(() => fetch('/api/updates'), 5000)
})
onDeactivated(() => clearInterval(timerId))
onActivated(() => {
timerId = setInterval(() => fetch('/api/updates'), 5000)
})
</script>With <KeepAlive>, the component stays in memory. Its timers and subscriptions keep running. Five cached tabs means five polling loops in the background.
Using ref instead of shallowRef
<script setup>
// WRONG: ref deeply observes the component object — unnecessary overhead
const current = ref(MyComponent)
// RIGHT: shallowRef watches only the reference, not what's inside
const current = shallowRef(MyComponent)
</script>Component objects can be large. Deep reactivity on them adds work Vue does not need to do.
Real-world usage
- Vue Router uses this pattern inside
<RouterView>to render matched route components - Nuxt pairs dynamic components with
<ClientOnly>to skip SSR for specific views - Form builders render field types (text, select, checkbox) from a config array
- E-commerce product pages switch between digital and physical product detail layouts
Follow-up questions
Q: What is the difference between <component :is> and v-if?
A: v-if is cleaner for one or two branches. <component :is> scales better when you have three or more options because the template stays flat. Both unmount the old component when the condition changes.
Q: When should I use <KeepAlive> and when should I let the component unmount?
A: Use <KeepAlive> when the component holds state worth keeping: form data, API results, scroll position. Let it unmount when you want a clean instance every time, or when the component has no state.
Q: Can I pass props to a dynamic component?
A: Yes, same as any static component: <component :is="current" :user-id="123" @save="handleSave" />. Props and events work normally.
Q: (Senior) How would you build a dynamic component system where each component manages its own lifecycle without the parent knowing about it?
A: Use <KeepAlive> with onActivated and onDeactivated inside each child. The parent just switches :is. Each component handles its own setup and cleanup. For shared state or cross-component callbacks, use provide/inject.
Examples
Basic tab interface
<script setup>
import { shallowRef } from 'vue'
import UserProfile from './UserProfile.vue'
import UserSettings from './UserSettings.vue'
import UserNotifications from './UserNotifications.vue'
const tabs = {
profile: UserProfile,
settings: UserSettings,
notifications: UserNotifications,
}
const currentTab = shallowRef(UserProfile)
</script>
<template>
<div class="tabs">
<button
v-for="(comp, name) in tabs"
:key="name"
:class="{ active: currentTab === comp }"
@click="currentTab = comp"
>
{{ name }}
</button>
<!-- No KeepAlive: switching tabs destroys the old component instance -->
<component :is="currentTab" />
</div>
</template>Switch to "settings" and back to "profile" and the profile component mounts fresh each time. Any data the user typed is gone. That's the default behavior, and sometimes exactly what you want.
Form builder with dynamic field types
<script setup>
import TextInput from './TextInput.vue'
import SelectInput from './SelectInput.vue'
import CheckboxInput from './CheckboxInput.vue'
const fieldComponents = {
text: TextInput,
select: SelectInput,
checkbox: CheckboxInput,
}
const formFields = [
{ type: 'text', name: 'username', label: 'Username' },
{ type: 'select', name: 'role', label: 'Role', options: ['Admin', 'User'] },
{ type: 'checkbox', name: 'active', label: 'Active account' },
]
</script>
<template>
<form>
<div v-for="field in formFields" :key="field.name">
<component
:is="fieldComponents[field.type]"
v-bind="field"
/>
</div>
</form>
</template>Each field renders the right input component based on its type. Adding a new field type is one entry in fieldComponents and one object in formFields. The template never changes.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.