Dynamic and Built-In Components

Switch components at runtime with <component :is>, keep their state with KeepAlive, and render into another part of the DOM with Teleport.

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

Sometimes the component to render is a decision, not a fixed tag. <component :is="…"> renders whatever you give it. Its is prop takes a string, which is read as an HTML tag name or the name of a globally registered component, or a component object itself. Because <script setup> imports are local to the file, pass the imported object rather than a name string.

KeepAlive

Switching away unmounts the old component, so its state is gone when you switch back. Wrap the switch in <KeepAlive> and the instance is cached instead of destroyed. Its props are include and exclude, which take a comma-delimited string, a regular expression or an array and match the component's name — inferred from the filename since Vue 3.2.34 — and max, which caps the cache and evicts the least recently used instance. A cached component's setup does not run again on return.

Teleport

<Teleport> renders its contents somewhere else in the DOM while the component stays where it is in your component tree, which is how a dialog escapes a parent's overflow or stacking context. to is required and takes a selector string or an element. disabled leaves the content in place and can be toggled. Since Vue 3.5, defer waits until the rest of the application has mounted before resolving the target, which is what you need when your own app renders that target.

What trips people up

<KeepAlive> accepts only one direct child component at a time.

Example

Example · vue
<!-- src/views/ProjectTabs.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import OverviewTab from '../components/OverviewTab.vue'
import SettingsTab from '../components/SettingsTab.vue'

const tabs = {
  overview: OverviewTab,
  settings: SettingsTab
}

type TabName = keyof typeof tabs

const names = Object.keys(tabs) as TabName[]
const current = ref<TabName>('overview')
</script>

<template>
  <nav>
    <button
      v-for="name in names"
      :key="name"
      type="button"
      :aria-current="name === current ? 'page' : undefined"
      @click="current = name"
    >
      {{ name }}
    </button>
  </nav>

  <keep-alive>
    <component :is="tabs[current]" />
  </keep-alive>
</template>

<style scoped>
nav {
  display: flex;
  gap: 0.5rem;
  margin-bottom: 1rem;
}
</style>

When to use it

  • Render a tab panel chosen from a map of components, keeping each panel's scroll and form state.
  • Render a dialog or toast into document.body so an ancestor's overflow cannot clip it.
  • Pick the right field component per entry in a form definition fetched from an API.

More examples

Teleport: a dialog rendered into body

The dialog is used wherever it makes sense in the tree, perhaps deep inside a sidebar, but its DOM lands at the end of body, so no ancestor's overflow: hidden or z-index can clip it. Props, events and slots keep working as usual, because only the rendered DOM moves, not the component. Note the scoped style still applies: the markup belongs to this component. If the target element is rendered by your own app rather than present in index.html, add the defer prop, which Vue 3.5 introduced for exactly that case.

Example · vue
<!-- src/components/ModalDialog.vue -->
<script setup lang="ts">
defineProps<{
  open: boolean
  title: string
}>()

const emit = defineEmits<{
  close: []
}>()
</script>

<template>
  <teleport to="body">
    <div v-if="open" role="dialog" :aria-label="title">
      <h4>{{ title }}</h4>
      <slot />
      <button type="button" @click="emit('close')">Close</button>
    </div>
  </teleport>
</template>

<style scoped>
div[role='dialog'] {
  position: fixed;
  inset: 20% 25% auto;
  background: #fff;
  border: 1px solid #d0d0d0;
  padding: 1rem;
}
</style>

KeepAlive with include and max

KeepAlive works on a v-if toggle, not only on <component :is>. Hiding the results and showing them again gives back the same instance with its scroll position, entered text and fetched data intact. include="SearchResults" is a comma-delimited string of component names; pass a regular expression or an array with v-bind instead, as :include="/Tab$/". max="5" makes the cache an LRU: the sixth distinct component evicts the least recently used one.

Example · vue
<!-- src/views/SearchPage.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import SearchResults from '../components/SearchResults.vue'

const showResults = ref(true)
</script>

<template>
  <button type="button" @click="showResults = !showResults">
    {{ showResults ? 'Hide results' : 'Show results' }}
  </button>

  <!-- include matches the name inferred from SearchResults.vue -->
  <keep-alive include="SearchResults" :max="5">
    <search-results v-if="showResults" />
  </keep-alive>
</template>

<style scoped>
button {
  margin-bottom: 1rem;
}
</style>

is with an HTML tag name

is accepts a plain tag name, which saves writing the same content twice under a v-if and a v-else. Vue drops an attribute whose value is null or undefined, so the span gets no empty href. The same trick gives a heading component a level prop with :is="'h' + level". One limitation to know: v-model does not work on a <component :is> that resolves to a native element, only on a real component.

Example · vue
<!-- src/components/SmartLabel.vue -->
<script setup lang="ts">
defineProps<{
  label: string
  href?: string
}>()
</script>

<template>
  <!-- renders <a> when there is an href, <span> otherwise -->
  <component :is="href ? 'a' : 'span'" :href="href">
    {{ label }}
  </component>
</template>

<style scoped>
a {
  text-decoration: underline;
}
</style>

Discussion

  • Be the first to comment on this lesson.