| |

Vue.js 46 🟢 Dynamic Component Rendering with

⏱️ Estimated read: 20 min | |

A template usually names its components. <UserCard /> renders a UserCard, and that is the end of the story. But a tabbed interface does not know which panel to render until the user picks a tab. A plugin system loads components the application has never heard of. A wizard renders one step at a time from a list of step definitions. In all of these, the component is not named in the template; it is chosen at runtime from data. Vue’s <component> element and its :is prop are the mechanism. The tag is a placeholder, and :is supplies the actual component, which can be a registered name, a component definition, or a string tag.

Key point: <component :is="..."> is a placeholder element whose tag is determined at runtime. The value of :is can be a component’s registered name, a component definition object, or a native HTML tag name. Vue resolves it on each render, so changing the value swaps the component.


Why dynamic components exist

The tab problem. A tabbed interface has several panels, and only one is visible. Rendering all of them and hiding the inactive ones wastes memory and runs their lifecycle hooks. Rendering one at a time and swapping requires a way to choose the component from the active tab’s data.

The plugin problem. A plugin registry maps a name to a component that was loaded dynamically. The application does not import the component at compile time; it looks it up by name and renders it. The <component> element accepts the resolved component and renders it.

The wizard problem. A multi-step form defines each step as a component and stores the current step in state. The template renders <component :is="steps[currentStep]" />, and the step advances by changing the index.

The layout problem. A page layout may render different content components depending on the route, the user’s role, or a feature flag. The <component> element lets the layout remain generic while the content is chosen by data.

The native-tag problem. The :is value can be a native HTML tag name, such as 'div', 'button', or 'a'. This is how a component renders as a different element based on a prop, which is the foundation of the asChild and polymorphic component patterns.

The <KeepAlive> problem. Swapping components destroys the old one and creates the new one. A tabbed interface that loses the scroll position or the form draft when the user switches tabs is a poor experience. The <KeepAlive> component caches the inactive components and restores their state, and it works with <component> directly.

The async-component problem. A dynamic component can be an async component, defined with defineAsyncComponent. The template does not need to know; the <component> element renders it, and Suspense or the loading component handles the pending state.


a. The is prop

The :is prop is the value that determines which component is rendered. It accepts three kinds of values.

The registered name

A component registered globally or locally can be referenced by its name as a string.

<script setup>
import { shallowRef } from 'vue';
import TabHome from './TabHome.vue';
import TabSettings from './TabSettings.vue';

const currentTab = shallowRef('TabHome');
</script>

<template>
  <component :is="currentTab" />
  <button @click="currentTab = 'TabHome'">Home</button>
  <button @click="currentTab = 'TabSettings'">Settings</button>
</template>

The string 'TabHome' is resolved against the registered components. The component must be registered with that name for the string to resolve.

The component definition

The value can be the component object itself.

<script setup>
import { shallowRef } from 'vue';
import TabHome from './TabHome.vue';
import TabSettings from './TabSettings.vue';

const currentTab = shallowRef(TabHome);
</script>

<template>
  <component :is="currentTab" />
  <button @click="currentTab = TabHome">Home</button>
  <button @click="currentTab = TabSettings">Settings</button>
</template>

The component object is the import binding. It is the most direct form, and it avoids the name-resolution step.

The native tag

The value can be a native HTML tag name.

<script setup>
import { shallowRef } from 'vue';

const tag = shallowRef('button');
</script>

<template>
  <component :is="tag">Click me</component>
  <button @click="tag = tag === 'button' ? 'a' : 'button'">Toggle</button>
</template>

The :is="'button'" renders a <button> element. The :is="'a'" renders an <a> element. The attributes and events are applied to the rendered element.

The component-versus-element distinction

A string that matches a registered component name resolves to the component. A string that matches a native tag renders the tag. A string that matches neither produces a warning, and Vue renders the string as a tag.

The shallowRef recommendation

The Vue documentation recommends shallowRef for the component reference, because a component object is not meant to be deeply reactive. A ref would make the component object reactive, which is unnecessary and can cause performance issues.

const currentTab = shallowRef(TabHome);

The shallowRef stores the component reference without deep reactivity.


b. The common patterns

The <component> element is the foundation of several recurring patterns. The patterns are the reason the element exists.

The tabbed interface

The tabs store the active component, and the template renders it.

<script setup>
import { shallowRef } from 'vue';
import TabHome from './TabHome.vue';
import TabSettings from './TabSettings.vue';
import TabProfile from './TabProfile.vue';

const tabs = {
  home: TabHome,
  settings: TabSettings,
  profile: TabProfile,
};

const current = shallowRef('home');
</script>

<template>
  <nav>
    <button
      v-for="(_, name) in tabs"
      :key="name"
      @click="current = name"
      :class="{ active: current === name }"
    >
      {{ name }}
    </button>
  </nav>
  <component :is="tabs[current]" />
</template>

The tabs object maps the tab names to the components. The current ref selects the active tab.

The <KeepAlive> wrapper

The <KeepAlive> component caches the inactive components. The state is preserved when the user switches tabs.

<template>
  <KeepAlive>
    <component :is="tabs[current]" />
  </KeepAlive>
</template>

The <KeepAlive> caches the component instance. The include and exclude props filter the cached components, and the max prop limits the cache size.

The keep-alive with conditions

A tabbed interface may want to cache only some of the tabs.

<KeepAlive :include="['TabHome', 'TabProfile']">
  <component :is="tabs[current]" />
</KeepAlive>

The include prop lists the component names to cache. The names must match the component’s name, which is the name property or the filename.

The plugin registry

A plugin registry maps a name to a component that was loaded dynamically.

<script setup>
import { shallowRef, markRaw } from 'vue';

const registry = new Map();

function register(name, component) {
  registry.set(name, markRaw(component));
}

const currentWidget = shallowRef(null);

function loadWidget(name) {
  import(`./widgets/${name}.vue`).then((module) => {
    register(name, module.default);
    currentWidget.value = module.default;
  });
}
</script>

<template>
  <component :is="currentWidget" v-if="currentWidget" />
</template>

The markRaw prevents Vue from making the component reactive, which is unnecessary and can break the component.

The wizard

A multi-step form defines each step as a component and stores the current step.

<script setup>
import { shallowRef, computed } from 'vue';
import StepOne from './StepOne.vue';
import StepTwo from './StepTwo.vue';
import StepThree from './StepThree.vue';

const steps = [StepOne, StepTwo, StepThree];
const currentStep = shallowRef(0);

const currentComponent = computed(() => steps[currentStep.value]);

function next() {
  if (currentStep.value < steps.length - 1) currentStep.value++;
}
</script>

<template>
  <component :is="currentComponent" />
  <button @click="next">Next</button>
</template>

The computed returns the component for the current step. The template renders it.

The polymorphic component

A component that renders as a different element based on a prop.

<script setup>
const props = defineProps({
  as: { type: String, default: 'button' },
});
</script>

<template>
  <component :is="props.as" class="btn">
    <slot />
  </component>
</template>

The as prop selects the tag. A caller passes as="a" to render a link, or as="button" to render a button.

The route component

Vue Router uses the same mechanism internally to render the matched route component.

<template>
  <RouterView />
</template>

The RouterView is a dynamic component that renders the matched route.

The transition integration

The <component> element composes with <Transition>.

<template>
  <Transition name="fade" mode="out-in">
    <component :is="tabs[current]" :key="current" />
  </Transition>
</template>

The :key is required, or the transition does not fire when the component changes.


c. The caveats and the advanced usage

The <component> element is straightforward, but several caveats apply.

The key attribute

A dynamic component that changes identity needs a :key attribute. Without it, Vue reuses the DOM element and the component’s state, which produces surprising results.

<component :is="currentComponent" :key="currentComponent.name" />

The key forces Vue to treat the component as a different instance when the value changes.

The <KeepAlive> interaction

The <KeepAlive> caches the component instances. A component that is inside a <KeepAlive> is not destroyed when it is swapped out; it is deactivated. The onActivated and onDeactivated hooks fire instead of onMounted and onUnmounted.

<script setup>
import { onActivated, onDeactivated } from 'vue';

onActivated(() => {
  console.log('activated');
});

onDeactivated(() => {
  console.log('deactivated');
});
</script>

The <Transition> interaction

The <Transition> component wraps the dynamic component and animates the enter and leave. The mode prop controls the sequence, and the :key ensures the transition fires.

The Suspense interaction

An async dynamic component suspends, and the nearest <Suspense> boundary shows the fallback.

<Suspense>
  <component :is="asyncComponent" />
  <template #fallback>Loading...</template>
</Suspense>

The is prop with v-bind

The is prop can be bound with v-bind.

<component :is="dynamicComponent" v-bind="props" />

The v-bind passes the props to the rendered component.

The is prop with a native tag and attrs

A native tag rendered through <component> receives the attributes and the event handlers.

<component :is="'button'" @click="handleClick" :disabled="disabled">
  Click
</component>

The @click and :disabled are applied to the rendered <button>.

The name resolution

A string is is resolved against the registered components. The component must be registered under that name.

// Local registration
import TabHome from './TabHome.vue';

// The component is registered as "TabHome".

A component imported in <script setup> is registered under its filename without the extension. The string 'TabHome' resolves to the imported component.

The markRaw recommendation

A component object should not be made reactive. The markRaw function marks it as raw, which prevents Vue from making it reactive.

const registry = new Map();

function register(name, component) {
  registry.set(name, markRaw(component));
}

The markRaw is used when the component is stored in a reactive collection.

The async component

A dynamic component can be an async component.

import { defineAsyncComponent, shallowRef } from 'vue';

const AsyncEditor = defineAsyncComponent(() => import('./Editor.vue'));
const current = shallowRef(AsyncEditor);

The template renders it, and the loading component is shown while the chunk loads.

The resolveDynamicComponent

The resolveDynamicComponent function is the underlying mechanism. It is rarely called directly, but it is useful in render functions.

import { resolveDynamicComponent } from 'vue';

const component = resolveDynamicComponent(props.is);

The function resolves the value to a component or a tag.


Complete Example Session

<!-- ============================================
     PART 1: THE BASIC DYNAMIC COMPONENT
     ============================================ -->

<script setup>
import { shallowRef } from 'vue';
import TabHome from './TabHome.vue';
import TabSettings from './TabSettings.vue';

const current = shallowRef(TabHome);
</script>

<template>
  <component :is="current" />
  <button @click="current = TabHome">Home</button>
  <button @click="current = TabSettings">Settings</button>
</template>

<!-- ============================================
     PART 2: THE TABBED INTERFACE
     ============================================ -->

<script setup>
import { shallowRef } from 'vue';
import TabHome from './TabHome.vue';
import TabSettings from './TabSettings.vue';
import TabProfile from './TabProfile.vue';

const tabs = {
  home: TabHome,
  settings: TabSettings,
  profile: TabProfile,
};

const current = shallowRef('home');
</script>

<template>
  <nav>
    <button
      v-for="(_, name) in tabs"
      :key="name"
      @click="current = name"
      :class="{ active: current === name }"
    >
      {{ name }}
    </button>
  </nav>
  <component :is="tabs[current]" />
</template>

<!-- ============================================
     PART 3: THE KEEPALIVE
     ============================================ -->

<template>
  <KeepAlive>
    <component :is="tabs[current]" />
  </KeepAlive>
</template>

<!-- ============================================
     PART 4: THE TRANSITION
     ============================================ -->

<template>
  <Transition name="fade" mode="out-in">
    <component :is="tabs[current]" :key="current" />
  </Transition>
</template>

<style scoped>
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>

<!-- ============================================
     PART 5: THE POLYMORPHIC COMPONENT
     ============================================ -->

<script setup>
const props = defineProps({
  as: { type: String, default: 'button' },
});
</script>

<template>
  <component :is="props.as" class="btn">
    <slot />
  </component>
</template>

<!-- Usage -->
<!-- <PolyButton as="a" href="/about">About</PolyButton> -->
<!-- <PolyButton as="button" @click="save">Save</PolyButton> -->

<!-- ============================================
     PART 6: THE PLUGIN REGISTRY
     ============================================ -->

<script setup>
import { shallowRef, markRaw } from 'vue';

const registry = new Map();
const current = shallowRef(null);

function register(name, component) {
  registry.set(name, markRaw(component));
}

async function load(name) {
  const module = await import(`./widgets/${name}.vue`);
  register(name, module.default);
  current.value = module.default;
}
</script>

<template>
  <component :is="current" v-if="current" />
  <button @click="load('Chart')">Load chart</button>
  <button @click="load('Table')">Load table</button>
</template>

<!-- ============================================
     PART 7: THE WIZARD
     ============================================ -->

<script setup>
import { shallowRef, computed } from 'vue';
import StepOne from './StepOne.vue';
import StepTwo from './StepTwo.vue';
import StepThree from './StepThree.vue';

const steps = [StepOne, StepTwo, StepThree];
const currentStep = shallowRef(0);
const currentComponent = computed(() => steps[currentStep.value]);
</script>

<template>
  <component :is="currentComponent" />
  <button @click="currentStep++" :disabled="currentStep >= steps.length - 1">Next</button>
</template>

<!-- ============================================
     PART 8: THE SUSPENSE
     ============================================ -->

<script setup>
import { defineAsyncComponent, shallowRef } from 'vue';
const AsyncEditor = defineAsyncComponent(() => import('./Editor.vue'));
const current = shallowRef(AsyncEditor);
</script>

<template>
  <Suspense>
    <component :is="current" />
    <template #fallback>Loading editor...</template>
  </Suspense>
</template>

<!-- ============================================
     PART 9: THE NATIVE TAG
     ============================================ -->

<script setup>
import { shallowRef } from 'vue';
const tag = shallowRef('button');
</script>

<template>
  <component :is="tag" @click="tag = tag === 'button' ? 'a' : 'button'">
    Click to toggle
  </component>
</template>

<!-- ============================================
     PART 10: THE COMPLETE PATTERN
     ============================================ -->

<!--
1. Use shallowRef for the component reference.
2. Use the component object for directness.
3. Use the registered name for string-based selection.
4. Use :key when the component changes identity.
5. Use KeepAlive to cache the inactive components.
6. Use Transition to animate the swap.
7. Use Suspense for async components.
8. Use markRaw in a registry.
9. Use the :is prop with native tags for polymorphic components.
10. The component tree is chosen by data.
-->

The ten parts showed the basic dynamic component, the tabbed interface, the KeepAlive, the Transition, the polymorphic component, the plugin registry, the wizard, the Suspense, the native tag, and the complete pattern.


Quick Reference

The is Values

ValueExampleRenders
Registered name'TabHome'The registered component
Component objectTabHomeThe component
Native tag'button'A <button> element
Async componentdefineAsyncComponent(...)The loaded component

The Common Patterns

PatternData Structure
TabsshallowRef('home') + tabs map
WizardshallowRef(0) + computed
PluginMap + markRaw
Polymorphicas prop + :is

The Composition

ComponentPurpose
<KeepAlive>Cache inactive components
<Transition>Animate the swap
<Suspense>Handle async components

The Lifecycle Hooks

HookFires when
onMountedThe component is created
onUnmountedThe component is destroyed
onActivatedInside a <KeepAlive> and activated
onDeactivatedInside a <KeepAlive> and deactivated

The Recommendations

ItemRecommendation
Component referenceshallowRef
Component in a registrymarkRaw
Component identity change:key
Cache inactive components<KeepAlive>

Best Practices

✅ Do This:

<!-- Use shallowRef for the component reference. -->
const current = shallowRef(TabHome);  <!-- ✅ -->

<!-- Use the component object for directness. -->
<component :is="current" />  <!-- ✅ -->

<!-- Use :key when the component changes identity. -->
<component :is="current" :key="current.name" />  <!-- ✅ -->

<!-- Use KeepAlive to cache the inactive components. -->
<KeepAlive><component :is="current" /></KeepAlive>  <!-- ✅ -->

<!-- Use markRaw in a registry. -->
registry.set(name, markRaw(component));  <!-- ✅ -->

<!-- Use Suspense for async components. -->
<Suspense><component :is="async" /></Suspense>  <!-- ✅ -->

<!-- Use the :is prop with native tags for polymorphic components. -->
<component :is="props.as" />  <!-- ✅ -->

❌ Don’t Do This:

<!-- Don't use a deep ref for the component. -->
const current = ref(TabHome);  <!-- ❌ Unnecessary reactivity -->

<!-- Don't forget the :key when identity changes. -->
<component :is="current" />  <!-- ❌ Reuses the DOM -->

<!-- Don't store a component in a reactive collection without markRaw. -->
map.set(name, component);  <!-- ❌ Vue makes it reactive -->

<!-- Don't expect KeepAlive to work without names. -->
<KeepAlive include="TabHome">  <!-- ❌ The component needs a name -->

<!-- Don't use a computed for a static component. -->
const c = computed(() => TabHome);  <!-- ❌ Unnecessary -->

<!-- Don't put a component in the data of an Options API component. -->
data() { return { current: TabHome }; }  <!-- ❌ Reactive -->

Common Pitfalls

PitfallWhy It HappensFix
Component not resolvedName not registeredRegister with the name
State not preservedNo KeepAliveWrap with <KeepAlive>
Transition does not fireMissing :keyAdd the key
Component reactivityDeep refUse shallowRef
Registry breaksNo markRawMark the component raw
Async component errorNo SuspenseWrap with <Suspense>
Native tag ignoredWrong valueUse a valid tag name

Real-World Examples

1. Basic Dynamic Component

<component :is="current" />

2. Tabbed Interface

<component :is="tabs[current]" />

3. KeepAlive

<KeepAlive><component :is="current" /></KeepAlive>

4. Transition

<Transition mode="out-in"><component :is="current" :key="current" /></Transition>

5. Polymorphic Component

<component :is="props.as" />

6. Plugin Registry

registry.set(name, markRaw(component));

7. Wizard

<component :is="steps[currentStep]" />

8. Suspense

<Suspense><component :is="async" /></Suspense>

9. Native Tag

<component :is="'button'" @click="handleClick" />

10. ShallowRef

const current = shallowRef(TabHome);

Visual

The Dynamic Component

┌────────────────────────────────────────────────────────────┐
│  THE DYNAMIC COMPONENT                                     │
│                                                            │
│  <component :is="current" />                               │
│                                                            │
│  current = TabHome                                         │
│  ┌──────────────────────────────────────────────────┐     │
│  │  <component :is="TabHome" />                      │     │
│  │  → <TabHome />                                    │     │
│  └──────────────────────────────────────────────────┘     │
│                                                            │
│  current = TabSettings                                     │
│  ┌──────────────────────────────────────────────────┐     │
│  │  <component :is="TabSettings" />                  │     │
│  │  → <TabSettings />                                │     │
│  └──────────────────────────────────────────────────┘     │
│                                                            │
│  current = 'button'                                        │
│  ┌──────────────────────────────────────────────────┐     │
│  │  <component :is="'button'" />                     │     │
│  │  → <button>                                       │     │
│  └──────────────────────────────────────────────────┘     │
│                                                            │
│  The :is value selects what is rendered.                   │
└────────────────────────────────────────────────────────────┘

The KeepAlive Cache

┌────────────────────────────────────────────────────────────┐
│  THE KEEPALIVE CACHE                                       │
│                                                            │
│  WITHOUT KEEPALIVE                                         │
│  ┌──────────────────────────────────────────────────┐     │
│  │  TabHome → TabSettings                            │     │
│  │    TabHome is destroyed.                          │     │
│  │    TabSettings is created.                        │     │
│  │    The scroll position and form state are lost.   │     │
│  └──────────────────────────────────────────────────┘     │
│                                                            │
│  WITH KEEPALIVE                                            │
│  ┌──────────────────────────────────────────────────┐     │
│  │  TabHome → TabSettings                            │     │
│  │    TabHome is deactivated (not destroyed).        │     │
│  │    TabSettings is created.                        │     │
│  │    TabHome's state is preserved.                  │     │
│  │                                                   │     │
│  │  TabSettings → TabHome                            │     │
│  │    TabHome is activated (not created).            │     │
│  │    Its state is restored.                         │     │
│  └──────────────────────────────────────────────────┘     │
└────────────────────────────────────────────────────────────┘

The Polymorphic Component

┌────────────────────────────────────────────────────────────┐
│  THE POLYMORPHIC COMPONENT                                 │
│                                                            │
│  <Button as="button">Save</Button>                         │
│  ┌──────────────────────────────────────────────────┐     │
│  │  <component :is="'button'">Save</component>       │     │
│  │  → <button>Save</button>                          │     │
│  └──────────────────────────────────────────────────┘     │
│                                                            │
│  <Button as="a" href="/about">About</Button>               │
│  ┌──────────────────────────────────────────────────┐     │
│  │  <component :is="'a'" href="/about">About</...>   │     │
│  │  → <a href="/about">About</a>                     │     │
│  └──────────────────────────────────────────────────┘     │
│                                                            │
│  The same component renders as different elements.         │
└────────────────────────────────────────────────────────────┘

The Composition

┌────────────────────────────────────────────────────────────┐
│  THE COMPOSITION                                           │
│                                                            │
│  <Suspense>                                                │
│    <KeepAlive>                                             │
│      <Transition mode="out-in">                            │
│        <component :is="current" :key="current.name" />     │
│      </Transition>                                         │
│    </KeepAlive>                                            │
│  </Suspense>                                               │
│                                                            │
│  ┌──────────────────────────────────────────────────┐     │
│  │  Suspense  → handles async components             │     │
│  │  KeepAlive → caches the inactive components       │     │
│  │  Transition → animates the swap                   │     │
│  │  component  → selects the component               │     │
│  │  :key       → forces the transition to fire       │     │
│  └──────────────────────────────────────────────────┘     │
└────────────────────────────────────────────────────────────┘

Summary

ItemValue
<component>A placeholder element
:isSelects the rendered component or tag
Registered nameA string that resolves to a component
Component objectThe import binding
Native tagA string like 'button'
shallowRefThe recommended ref type
markRawPrevents component reactivity
<KeepAlive>Caches inactive components
<Transition>Animates the swap
<Suspense>Handles async components

Key takeaways:

  • <component :is="..."> is a placeholder whose tag is determined at runtime. The value of :is can be a registered name, a component definition, or a native tag. Vue resolves it on each render .
  • The component object is the most direct form. A string name requires the component to be registered under that name. The component object avoids the resolution step .
  • The shallowRef is the recommended ref type. A component object should not be deeply reactive. The shallowRef stores the reference without making it reactive .
  • The :key forces the component identity. Without a key, Vue reuses the DOM element when the component changes, which produces surprising results. The key ensures a fresh instance .
  • <KeepAlive> caches the inactive components. The tabbed interface that loses its state when the user switches tabs is a poor experience. The <KeepAlive> preserves the state, and the onActivated and onDeactivated hooks replace the mount and unmount hooks .
  • The polymorphic component uses a native tag as the :is value. A component that renders as a different element based on a prop is the foundation of the asChild and polymorphic component patterns .
  • The registry uses markRaw. A component stored in a reactive collection should be marked raw to prevent Vue from making it reactive, which is unnecessary and can break the component .
  • The composition with <Suspense>, <KeepAlive>, and <Transition> covers the common cases. The Suspense handles async components, the KeepAlive caches the inactive ones, and the Transition animates the swap .

Remember: The <component :is="..."> element is the mechanism for choosing a component at runtime. The :is value selects what is rendered, whether it is a registered name, a component object, or a native tag. The tabbed interface, the plugin registry, the wizard, and the polymorphic component are all built on this element. The <KeepAlive> caches the inactive components, the <Transition> animates the swap, and the <Suspense> handles the async components. The result is a template that is generic over the component it renders, and an application where the component tree is chosen by data rather than by code.


Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!