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-rootbut 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
useFetchwith 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
readonlyto 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.