4 min read
On this page

Provide/Inject and Teleport

Props work well for parent-child communication, but they fall apart when data needs to travel through many layers. A theme setting used by a deeply nested button should not be passed through every intermediate component. Vue solves this with provide/inject — dependency injection for component trees. Teleport solves a different problem: rendering DOM nodes outside the component hierarchy, which is essential for modals, tooltips, and toast notifications.

Provide/Inject

provide makes a value available to all descendant components. inject retrieves it. No prop drilling required.

<!-- App.vue or a layout component -->
<script setup lang="ts">
import { provide, ref } from 'vue'

const theme = ref<'light' | 'dark'>('light')
const toggleTheme = () => {
  theme.value = theme.value === 'light' ? 'dark' : 'light'
}

provide('theme', theme)
provide('toggleTheme', toggleTheme)
</script>

Any descendant, no matter how deep, can inject these values:

<!-- components/DeepChild.vue -->
<script setup lang="ts">
import { inject, type Ref } from 'vue'

const theme = inject<Ref<'light' | 'dark'>>('theme')
const toggleTheme = inject<() => void>('toggleTheme')
</script>

<template>
  <div :class="theme">
    <button @click="toggleTheme">Switch to {{ theme === 'light' ? 'dark' : 'light' }}</button>
  </div>
</template>

Injection Keys

String keys work for small apps, but they risk collisions and provide no type safety. Use Symbol keys with a typed helper:

// injection-keys.ts
import type { InjectionKey, Ref } from 'vue'

export const ThemeKey: InjectionKey<Ref<'light' | 'dark'>> = Symbol('theme')
export const ToggleThemeKey: InjectionKey<() => void> = Symbol('toggleTheme')
<!-- Provider -->
<script setup lang="ts">
import { provide, ref } from 'vue'
import { ThemeKey, ToggleThemeKey } from '@/injection-keys'

const theme = ref<'light' | 'dark'>('light')
provide(ThemeKey, theme)
provide(ToggleThemeKey, () => {
  theme.value = theme.value === 'light' ? 'dark' : 'light'
})
</script>
<!-- Consumer -->
<script setup lang="ts">
import { inject } from 'vue'
import { ThemeKey } from '@/injection-keys'

const theme = inject(ThemeKey) // Type: Ref<'light' | 'dark'> | undefined
</script>

Now TypeScript knows exactly what type theme is. No casting needed.

Default Values

inject returns undefined if no ancestor provides the value. For required injections, provide a default or throw:

// Provide a default
const theme = inject(ThemeKey, ref('light'))

// Or throw if missing (useful for components that require a provider)
const theme = inject(ThemeKey)
if (!theme) {
  throw new Error('ThemeKey not provided. Wrap this component in a ThemeProvider.')
}

Read-Only Provide

If consumers should not mutate provided state, wrap it with readonly:

<script setup lang="ts">
import { provide, ref, readonly } from 'vue'

const count = ref(0)
provide('count', readonly(count)) // Consumers cannot mutate
provide('increment', () => count.value++) // They call this instead
</script>

This enforces one-way data flow even with provide/inject, keeping mutations predictable and traceable.

When to Use Provide/Inject vs Props vs Pinia

Props are for direct parent-child communication. Use them when data travels one or two levels.

Provide/inject is for data shared across a subtree of components. A form context, a theme, a layout configuration. The provided values are scoped to the provider's descendant tree -- two separate <ThemeProvider> components create two independent contexts.

Pinia is for global application state. Authentication, shopping carts, user preferences. Pinia stores are singletons accessible from any component, not scoped to a tree.

The distinction matters for testing: provided values can be overridden per test by wrapping the component in a provider. Pinia stores need createTestingPinia. Props are just arguments.

Teleport

Teleport renders a component's DOM to a different location in the page. The component stays in the Vue component tree (props, events, and provide/inject work normally), but its HTML output appears elsewhere.

The classic use case is modals. A modal component lives inside a deeply nested component for logical reasons, but its DOM needs to be at the top level of <body> to avoid z-index and overflow issues.

<!-- components/ConfirmDialog.vue -->
<script setup lang="ts">
defineProps<{
  open: boolean
  message: string
}>()

defineEmits<{
  (e: 'confirm'): void
  (e: 'cancel'): void
}>()
</script>

<template>
  <Teleport to="body">
    <div v-if="open" class="modal-overlay" @click.self="$emit('cancel')">
      <div class="modal">
        <p>{{ message }}</p>
        <div class="modal-actions">
          <button @click="$emit('cancel')">Cancel</button>
          <button @click="$emit('confirm')">Confirm</button>
        </div>
      </div>
    </div>
  </Teleport>
</template>
<!-- Parent -- deeply nested somewhere -->
<script setup lang="ts">
import { ref } from 'vue'

const showConfirm = ref(false)

function handleConfirm() {
  deleteItem()
  showConfirm.value = false
}
</script>

<template>
  <button @click="showConfirm = true">Delete</button>
  <ConfirmDialog
    :open="showConfirm"
    message="This will permanently delete the item."
    @confirm="handleConfirm"
    @cancel="showConfirm = false"
  />
</template>

The modal DOM appears as a direct child of <body>, but the component still receives props and emits events from its logical parent.

Teleport Targets

to accepts any CSS selector:

<Teleport to="#toast-container">
  <div class="toast">Saved successfully.</div>
</Teleport>

The target element must exist in the DOM when the Teleport renders. In Nuxt apps, add target elements in app.vue:

<!-- app.vue -->
<template>
  <NuxtLayout>
    <NuxtPage />
  </NuxtLayout>
  <div id="modal-container" />
  <div id="toast-container" />
</template>

Disabling Teleport

The disabled prop keeps the content in its original location:

<Teleport to="body" :disabled="isMobile">
  <Sidebar />
</Teleport>

On mobile, the sidebar renders inline. On desktop, it teleports to body.

Suspense (Experimental)

<Suspense> handles async components and async setup functions. It shows fallback content until the async operation resolves:

<template>
  <Suspense>
    <template #default>
      <AsyncDashboard />
    </template>
    <template #fallback>
      <div class="loading">Loading dashboard...</div>
    </template>
  </Suspense>
</template>

Suspense is still experimental in Vue 3, but Nuxt uses it internally for page transitions and data loading. You rarely need to use <Suspense> directly in Nuxt.

Common Pitfalls

  • Using provide/inject for everything. It makes data flow invisible. Props make dependencies explicit. Use provide/inject only when prop drilling genuinely becomes a problem (3+ levels).
  • Forgetting that inject can return undefined. If no ancestor provides the value, inject returns undefined. Always handle this case.
  • Teleport target not existing yet. If you teleport to #modal-root but that div has not been rendered, Vue silently fails.
  • Assuming Teleport moves the component. Teleport only moves DOM. The component stays in the logical tree. Props, events, and lifecycle hooks work relative to the logical parent.
  • Overusing Suspense. It is experimental and its API may change. In Nuxt, prefer useFetch with loading states over manual Suspense boundaries.

Key Takeaways

  • Provide/inject solves prop drilling by making values available to all descendants. Use Symbol keys with InjectionKey<T> for type safety.
  • Provide reactive refs for reactive consumers. Use readonly to prevent consumer mutations.
  • Teleport renders DOM outside the component tree while preserving the logical parent-child relationship.
  • Choose props for direct communication, provide/inject for subtree sharing, and Pinia for global state.
  • Suspense handles async components but is still experimental -- Nuxt abstracts it for most use cases.