Skip to content
intermediate Phase 6 · Slots, Dynamic Components, and Provide/Inject

Dynamic Components with v-bind is

Swap rendered components at runtime with the component built-in.

40m
0 problems
Topic Progress 0%

One Outlet, Many Components: The <component :is> Built-in

A settings page has four panels — Profile, Security, Billing, Notifications — and the active one depends on runtime state: a tab click, a route param, a feature flag. A v-if chain works at four panels; at forty it is unmanageable. Vue's built-in for runtime component selection is the component element:

<script setup>
import { shallowRef } from 'vue'
import ProfilePanel from './ProfilePanel.vue'
import SecurityPanel from './SecurityPanel.vue'
import BillingPanel from './BillingPanel.vue'

const panels = { ProfilePanel, SecurityPanel, BillingPanel }
const active = shallowRef(ProfilePanel)
</script>

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

<component> is a built-in wrapper that renders whatever component definition its is attribute resolves to. The binding is a normal reactive one: assign a different definition to active and Vue renders that component in place of the previous one — no manual mount calls, no template editing, no string parsing.

Props and attributes flow through untouched:

<component :is="active" :user="currentUser" class="panel" @closed="close" />

:user reaches the active component as its declared prop; class and listeners that the active component doesn't declare fall through to its root per the usual attrs rules. From the active component's point of view, it was rendered normally — it has no idea it was selected at runtime, which is exactly why any component can become a dynamic one without modification.

The shallowRef in the example is deliberate. The value stored is a component definition — a large object that Vue must never deeply traverse or reactive-proxy (the reactivity system would walk its entire setup graph for nothing, and proxying component definitions can break identity checks). The Vue style guide's rule: store component definitions in shallowRef, never ref. A ref around a component object often works by accident and then fails in subtle ways (deep proxies interfering with vnode type identity, wasted traversal on every access).

What happens on a swap is the fact that matters most for UI behavior: a different component type is a full unmount of the old instance and a fresh mount of the new one. Each panel's local refs start over, onMounted runs again, scroll position and form drafts vanish. If the swapped-out component needs to preserve state when the user comes back, that is the job of keep-alive wrapping this very element — the third chapter builds the tab system that shows why you'd reach for it.

<component> is also where content projection meets runtime selection: slot markup written in the parent renders inside whichever component is active, exactly as it would behind a static tag, so the dynamic outlet composes freely with the projection model from Default Slots and Content Projection. Two integration points round out the mechanism. Wrap the outlet in <Suspense> and an async active component suspends the render until its dependencies resolve, with the fallback shown in the outlet's place — the same async boundary a static tag would receive. And render-function code reaches the identical logic directly: resolveDynamicComponent('my-tag') performs the very lookup the template's is binding performs, which is occasionally necessary when you construct vnodes programmatically instead of through a template.

A design note for review: dynamic components and v-if chains overlap but differ in ergonomics. v-if gives you explicit slots per branch and compile-time checking of each component's props; :is gives you data-driven selection that scales with a map and works with values computed at runtime (route resolution, plugin registries, user-uploaded widgets). Choose :is when the set of components is data; keep v-if when each branch is hand-wired and few.

What :is Accepts: Imported Objects, Registered Names, and Native Tags

The is attribute is polymorphic, and which form you use decides whether it works in your setup. Three accepted value kinds, in order of how much you should want them:

1. A component definition object — the imported SFC, a defineAsyncComponent result, or any object with render/setup. This is the natural form in <script setup>:

import UserCard from './UserCard.vue'
const current = shallowRef(UserCard)

Because the reference is direct, there is no registry lookup, no string typo surface, and tree-shaking sees the import. This form also covers the async case for free — swap in a lazily loaded panel and the dynamic outlet pauses rendering until the chunk resolves:

import { defineAsyncComponent } from 'vue'
const HeavyReport = defineAsyncComponent(() => import('./HeavyReport.vue'))

2. A registered name string. :is="'user-card'" performs a registry lookup — the component must be globally registered via app.component, or registered locally in the rendering component's components option (the Options API form; script setup's imports are bindings, not registry entries, so a string does not find them). Try :is="'profile-panel'" in a script-setup-only app and the dev console answers with Failed to resolve component: profile-panel and renders nothing useful. String resolution exists for plugin/registry ecosystems — design-system lookups, admin panel catalogs keyed by database names — not as the default in app code.

3. A native tag string. :is="'div'", :is="'a'", :is="'button'" renders that HTML element, which unlocks the polymorphic-element pattern:

<script setup>
defineProps<{
  as: 'button' | 'a' | 'router-link'
  href?: string
  to?: string
}>()
</script>

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

One button component that renders a real <button>, a real anchor, or a router link depending on the prop — attributes and listeners fall through to whichever root renders. This is the mechanism behind every as-prop design system. Note the mixed value works because a router-link string also resolves against the registry; the three value kinds coexist in one lookup.

Two pitfalls worth the memory slot:

  • String lookups in-DOM templates. In in-DOM templates the attribute itself is subject to HTML parsing, and while :is with a static value has historic quirks (is as a real attribute for custom elements), the dynamic binding form is the safe spelling everywhere. Prefer object values and these questions evaporate.
  • Identity across module instances. Two imports of "the same" file resolve to the same module in any normal bundler setup, but comparing a string name to an object, or mixing registry entries with imports of the same component, produces duplicate-instance behavior (two cached versions, double-mounted state). Pick one resolution channel per system — imports for app code, registry only inside the plugin/catalog boundary.

Two practical details about the channels close this out. When the bound object is an async component, the outlet honors its options — the eventual swap is an ordinary patch, with skeletons and error states covering the interim:

const HeavyReport = defineAsyncComponent({
  loader: () => import('./HeavyReport.vue'),
  loadingComponent: ReportSkeleton,
  delay: 200,
  errorComponent: LoadFailed
})

And regardless of how the active component arrived — imported object or registry string — <keep-alive include> still matches its name, because the cache keys on the resolved component definition itself, not on the reference you passed. Two tabs reaching the same component through different channels (one by import, one by registered name) share a single cache entry rather than forking into two instances of the same view — resolution channels affect lookup, not identity.

With resolution settled, the interesting part is behavioral: what a tab system built on dynamic components does to component state each time the user switches — and how :key interacts with the swap.

Tab State on Swap: Why Your Draft Disappears and the Three Fixes

Wire a real tab bar to the dynamic outlet and the first user complaint writes itself: half-filled forms in the Profile tab are empty again after visiting Billing and coming back. The mechanism is the unmount-on-swap behavior from the first chapter — and there are exactly three fixes, each with a different cost.

The tab system itself:

<script setup>
import { shallowRef } from 'vue'
import ProfileTab from './ProfileTab.vue'
import BillingTab from './BillingTab.vue'

const tabs = [
  { name: 'Profile', component: ProfileTab },
  { name: 'Billing', component: BillingTab }
]
const active = shallowRef(tabs[0].component)
</script>

<template>
  <button v-for="tab in tabs" :key="tab.name"
          @click="active = tab.component">{{ tab.name }}</button>
  <component :is="active" />
</template>

Fix 1 — lift the state up. The drafts live in the parent (or a store), and the tabs receive them as props:

<component :is="active" v-bind="drafts" @save="saveDraft" />

This is the default answer when the state has meaning outside the tab — a multi-step wizard whose data the final step submits, shared counters, anything the server will eventually see. The tab component becomes a view over owned state, and remounting is harmless. It costs prop/event plumbing per tab, which is precisely why it is the correct cost: state that must survive should not have been inside a disposable child in the first place.

Fix 2 — :key discipline. Vue patches in place when the new render produces the same component type; it remounts when the type differs. For tabs over the same component with different props (<component :is="EditorTab" :doc-id="activeDocId" />), the instance is reused and local state leaks between logical tabs — a draft from document 12 appears when opening document 15. The fix is explicit identity:

<component :is="EditorTab" :key="activeDocId" :doc-id="activeDocId" />

:key="activeDocId" forces a fresh instance per document. The symmetric mistake — slapping :key="Math.random()"-style keys on everything — turns every parent re-render into a remount storm. Keys encode logical identity: same identity, preserve instance; different identity, new instance.

Fix 3 — keep-alive, the cache. Wrap the outlet and deactivated tabs are cached instead of destroyed:

<keep-alive>
  <component :is="active" />
</keep-alive>

State, scroll position, and mounted lifecycles survive the round trip — the full mechanics of caching, include/exclude scoping, and the memory trade-off are the next topic, Caching with keep-alive. The one-line decision rule: lift state that matters beyond the tab; key by logical identity; cache the whole instance only when remounting itself is the cost you're paying — a heavy tab re-running its data fetch on every visit, a video resetting playback, a map resetting the viewport.

Two integrations complete the picture. With <Transition> around the outlet, swaps animate — but only when the active vnode has stable identity between renders, so <component :is="active" :key="active" /> matters here for the same reason :key matters for remounts: without it, patching can swallow the enter and leave hooks. And route-driven apps have used this machinery all along: <router-view> renders whichever component the matched route record declares, resolved through the same dynamic-component path, so lazy route chunks, route guards, and <keep-alive> wrapped around <router-view> are the primitive you just learned wearing router clothes.

The interview question that ties the chapter together is "what does switching tabs cost?" The complete answer names three things: the default — unmount and remount, so local state, scroll position, and onMounted effects are lost; the patch exception — same component type reuses the instance, a feature for props-driven views and a state leak across logical items until :key encodes identity; and the deliberate cache — keep-alive, covered next. A candidate who answers only "it re-renders" has missed the instance lifecycle, which is exactly the part users feel.

A review heuristic ties it together: count the user-visible losses on a swap — form drafts, scroll, animation position, in-flight effects. If the losses are data, lift the state. If the losses are expensive recomputation or interaction position, cache the instance. Dynamic components make swapping trivial; deciding what a swap should cost is the actual engineering.

Quiz

1. What three value kinds does <component :is="x" /> accept?

Question 1 options

2. In a <script setup> app, <component :is="'profile-panel' /> renders nothing and warns 'Failed to resolve component'. Why?

Question 2 options

3. Two tabs share one EditorTab component with different :doc-id values. A draft typed in tab A appears when opening tab B. What is happening, and what fixes it?

Question 3 options

4. Why should a component definition stored for :is live in a shallowRef rather than a ref?

Question 4 options

Flashcards

Question

What does <component :is="x" /> render, and how do props/attrs flow?

Answer

Whatever component definition is resolves to. Declared props and listeners pass through to the active component; undeclared attrs (class, style, data-*) fall through to its root. The selected component is unaware it was chosen at runtime, so any component works unchanged.

Question

The three accepted kinds of values for :is, and where each resolves.

Answer

1) Component definition object — imported SFC or defineAsyncComponent; no registry, tree-shakeable, the script-setup default. 2) Name string — registry lookup; only global app.component or components-option entries; script-setup imports are NOT findable. 3) Native tag string ('button','a') — renders the element, enabling as-prop polymorphic components.

Question

What is the instance lifecycle across a dynamic swap, and when does local state survive?

Answer

A different component type unmounts the old instance and mounts a fresh one — local refs reset, mounted hooks re-run, drafts and scroll are lost. The same type patches in place and reuses the instance (state survives, which leaks between logical items unless :key encodes identity). keep-alive caches deactivated instances to survive swaps deliberately.

Question

The three fixes for tab state loss on swap — and when each is right.

Answer

1) Lift state to the parent/store — when the data matters beyond the tab (wizards, server-bound drafts). 2) :key by logical identity — when one component serves many logical items and must remount per item. 3) keep-alive — when the instance itself is the cost: expensive fetches, scroll/animation/map position. Data losses → lift; recomputation/interaction losses → cache.

Question

Why does the style guide insist component definitions go in shallowRef?

Answer

Deep reactivity would traverse the definition's entire structure on every access and proxy an object Vue compares by identity — wasted work plus subtle identity bugs. shallowRef tracks the reference swap (all a dynamic outlet needs) without touching the value's internals.

Revision Notes

Key Takeaways

  • 1. <component :is> renders a runtime-selected definition with normal prop and attrs fallthrough — components need no changes to become dynamically selectable
  • 2. Resolution channels differ: objects always work, name strings need registry entries, native tags enable as-prop polymorphic wrappers
  • 3. Store definitions in shallowRef — deep proxies around component internals are waste and identity hazards
  • 4. Swaps unmount by default; same-type patches reuse instances, which is why logical identity needs :key
  • 5. Fix state loss by classifying it: data → lift up, identity → key, expensive instance → keep-alive

Interview Tips

  • • "How do dynamic components work?" — the component built-in renders whatever :is resolves to; give the three value kinds and note script-setup imports don't respond to string lookup
  • • "My tab component keeps stale state across tabs" — same-type patching reuses the instance; explain vnode identity and the :key-per-item fix
  • • "What does a swap actually do?" — unmount + mount for a different type, patch for the same; use it to segue into keep-alive as the deliberate cache
  • • "Why shallowRef for components?" — definitions are compared by identity, never traversed; deep reactivity would proxy internals and waste work

Cheat Sheet

Dynamic Components

<script setup>
import { shallowRef } from 'vue'          // NOT ref — definitions must be shallow
import Profile from './Profile.vue'
const active = shallowRef(Profile)        // object = default resolution channel
</script>
<component :is="active" :user="u" class="panel" @closed="close" />
<!-- props → active component · undeclared attrs → root fallthrough -->

is accepts: object (import / async component) · registry name string (global/components-option only — script-setup imports won't resolve) · native tag ('button'/'a' → as-prop polymorphism).

Swap semantics: different type = unmount + fresh mount (state, scroll, mounted hooks lost) · same type = patch, instance reused (state survives — leaks across logical items without :key).

State-loss decision:

Loss Fix
drafts/data with meaning beyond the tab lift state to parent/store, pass as props
one component, many logical items :key="itemId" → fresh instance per identity
expensive recompute / scroll / media position <keep-alive><component :is/></keep-alive>

v-if chain vs :is: v-if = few hand-wired branches with per-branch props checking; :is = data-driven sets (maps, registries, flags) that scale.

Continue Learning