16 — Teleport & Suspense
Teleport — Render Outside the Component Tree
Modal.vue
<script setup>
import { ref, computed } from 'vue'
const props = defineProps<{
modelValue: boolean
teleportTo?: string // configurable target, defaults to body
}>()
const emit = defineEmits(['update:modelValue'])
const target = computed(() => props.teleportTo ?? 'body')
const isOpen = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
</script>
<template>
<!-- ── Teleport: renders the content at the target element ── -->
<!-- The component's logic stays in the parent, but the DOM is moved. -->
<!-- Critical for: modals, tooltips, dropdowns, notifications. -->
<!-- Avoids: z-index stacking context issues, parent overflow clipping. -->
<Teleport :to="target" :disabled="!isOpen">
<!-- ── disabled: when true, content renders in-place (not teleported) ── -->
<!-- Useful for SSR (no document.body on server) or conditional teleporting -->
<div v-if="isOpen" class="modal-overlay" @click.self="isOpen = false">
<div class="modal-content">
<slot />
<button @click="isOpen = false">Close</button>
</div>
</div>
</Teleport>
</template>
Why Teleport Exists — Stacking Context Problem
stacking-context.css
/* ── Without Teleport, a modal inside a parent with overflow:hidden ── */
/* or a transformed ancestor (creates stacking context) gets CLIPPED. */
.parent-with-overflow {
overflow: hidden; /* clips any child that extends beyond bounds */
transform: translateZ(0); /* creates a new stacking context */
}
/* .modal inside .parent-with-overflow:
- z-index: 9999 is ignored (stacking context limits it to parent's context)
- overflow: hidden clips the modal backdrop
- The modal appears cut off or behind other elements outside the parent */
/* With Teleport to="body":
- Modal is a direct child of <body>, no parent stacking context
- z-index works globally
- No overflow clipping
- The modal renders above everything, regardless of where the component
that opens it sits in the component tree. */
Teleport with Multiple Targets — Dynamic Target
DropdownMenu.vue
<script setup>
import { ref, onMounted } from 'vue'
// ── Dynamic target: teleport to a ref'd container ──
// Useful for: app-specific containers (sidebars, drawer areas).
const customContainer = ref<HTMLElement | null>(null)
onMounted(() => {
// Find or create a container element to teleport into
customContainer.value = document.getElementById('dropdown-container')
})
</script>
<template>
<Teleport :to="customContainer" :disabled="!customContainer">
<div class="dropdown-menu">
<slot name="items" />
</div>
</Teleport>
<!-- ── Teleport to a CSS selector string ── -->
<!-- <Teleport to="#modal-portal"> → finds element with id="modal-portal" -->
<!-- The target MUST exist in the DOM before the Teleport mounts -->
</template>
Suspense — Async Component Orchestration
SuspenseExample.vue
<script setup>
import { ref, onErrorCaptured, defineAsyncComponent } from 'vue'
// ── Suspense: declarative async component loading ──
// Parent shows #fallback slot while async children load.
// ALL async children must resolve before #default renders.
const AsyncUserProfile = defineAsyncComponent(() => import('./UserProfile.vue'))
const AsyncActivityFeed = defineAsyncComponent(() => import('./ActivityFeed.vue'))
// ── State for error handling (Suspense doesn't handle errors) ──
const error = ref<Error | null>(null)
const retryKey = ref(0) // increment to force re-mount of async children
// ── onErrorCaptured: catches errors from async setup in children ──
// Must be in the PARENT of <Suspense>, not inside <Suspense> itself.
onErrorCaptured((err, instance, info) => {
console.error(`Error in ${instance?.$options?.name} during ${info}:`, err)
error.value = err
return false // stop propagation — don't crash the app
})
function retry() {
error.value = null
retryKey.value++ // key change forces component re-creation → re-runs async setup
}
</script>
<template>
<!-- ── Error state: shown when async child throws ── -->
<div v-if="error" class="error-boundary">
<h2>Something went wrong</h2>
<p>{{ error.message }}</p>
<button @click="retry">Retry</button>
</div>
<!-- ── Suspense boundary: shows fallback until ALL children resolve ── -->
<Suspense v-else>
<template #default>
<!-- key forces re-mount on retry -->
<div :key="retryKey">
<AsyncUserProfile />
<AsyncActivityFeed />
<!-- Both must resolve before this slot renders -->
</div>
</template>
<template #fallback>
<div class="loading-skeleton">
<div class="skeleton-line" />
<div class="skeleton-line" />
</div>
</template>
</Suspense>
</template>
UserProfileChild.vue
<script setup>
import { ref } from 'vue'
const user = ref(null)
// ── Top-level await: makes this an async component ──
// Suspense pauses rendering until this resolves.
// If it throws, onErrorCaptured in the parent catches it.
const res = await fetch('/api/user/profile')
user.value = await res.json()
</script>
<template>
<div>
<h2>{{ user.name }}</h2>
<p>{{ user.email }}</p>
</div>
</template>
Nested Suspense — Independent Loading Boundaries
NestedSuspense.vue
<template>
<!-- ── Outer Suspense: waits for page shell ── -->
<Suspense>
<template #default>
<Header />
<main>
<!-- ── Inner Suspense: independently loads sidebar ── -->
<!-- If sidebar is slow, the main content still shows -->
<Suspense>
<template #default>
<Sidebar />
</template>
<template #fallback>
<div class="sidebar-skeleton">Loading sidebar…</div>
</template>
</Suspense>
<!-- ── Inner Suspense: independently loads content ── -->
<Suspense>
<template #default>
<Content />
</template>
<template #fallback>
<div class="content-skeleton">Loading content…</div>
</template>
</Suspense>
</main>
</template>
<template #fallback>
<FullPageLoading />
</template>
</Suspense>
</template>
<!-- ── Why nested Suspense matters: ────────────────────
Without nested boundaries, if ANY async child is slow, ALL children
wait (outer fallback shows for everything). Nested boundaries allow
independent loading — fast parts render while slow parts show fallback. -->
💡 Tips & Tricks
tips.ts
// ── 1. Teleport target must exist before Teleport mounts ──
// Use onMounted to verify, or create the target element dynamically.
// For SSR: use :disabled="isServer" to render in-place on server.
// ── 2. Suspense + async setup + watch: register watchers BEFORE await ──
// Watchers created after top-level await won't fire during the initial load.
// Place all watch/watchEffect calls before the first await.
// ── 3. Teleport preserves reactivity ──
// The teleported content still belongs to the component that created it.
// Props, state, provide/inject all work as if the DOM was in the component tree.
// ── 4. Multiple Teleports to the same target append in order ──
// <Teleport to="#portal">A</Teleport>
// <Teleport to="#portal">B</Teleport> → #portal contains A then B.
// ── 5. Suspense suspensible: false shows loadingComponent instead of fallback ──
// defineAsyncComponent({ suspensible: false, loadingComponent: Spinner })
// → component shows Spinner (not Suspense fallback) while loading.
⚠️ Edge Cases & Gotchas
edge-cases.ts
// ── 1. Teleport target must exist in DOM before Teleport renders ──
// If target is dynamically created, ensure it's available in onMounted.
// Otherwise: "Failed to locate target" warning, content renders in-place.
// ── 2. SSR: Teleport to="body" fails on server (no document) ──
// Use :disabled="typeof document === 'undefined'" to render in-place on server.
// Or: create a <div id="portal"> in your root HTML and teleport to that.
// ── 3. Suspense doesn't retry automatically on error ──
// After an error, the fallback slot stays. Must re-mount (change :key) to retry.
// onErrorCaptured + a retry button that increments a key is the standard pattern.
// ── 4. Async setup + lifecycle hooks: hooks after await may not fire ──
// onMounted registered AFTER the top-level await may not fire if the component
// is unmounted while still awaiting. Register all hooks before await.
// ── 5. Teleport content is NOT a child of the target in Vue's component tree ──
// The DOM is moved, but the component tree (provide/inject, parent/child)
// is preserved. inject() works as if the content was in its original position.
// ── 6. Suspense fallback shows for the ENTIRE boundary ──
// If you have 3 async children and 1 is slow, all 3 wait.
// Split into separate Suspense boundaries for independent loading.
🧠 Spot the Bug
A modal rendered inside a parent with overflow: hidden and transform is clipped and has broken z-index.
ModalBug.vue
<script setup>
const isOpen = ref(false)
</script>
<template>
<!-- Parent has overflow: hidden + transform (creates stacking context) -->
<div class="dashboard" style="overflow: hidden; transform: translateZ(0);">
<button @click="isOpen = true">Open Modal</button>
<!-- ❌ Modal rendered inside .dashboard — clipped by overflow, z-index limited -->
<div v-if="isOpen" class="modal" style="z-index: 9999;">
<slot />
</div>
</div>
</template>
Answer
The modal is a DOM child of .dashboard, which has overflow: hidden (clips the modal) and transform: translateZ(0) (creates a new stacking context — z-index: 9999 is scoped to the parent's context, not global).
Fix — use Teleport to render the modal at body level:
ModalFixed.vue
<template>
<div class="dashboard" style="overflow: hidden; transform: translateZ(0);">
<button @click="isOpen = true">Open Modal</button>
<!-- ✅ Teleport: modal DOM is at body level, no parent stacking context -->
<Teleport to="body">
<div v-if="isOpen" class="modal" style="z-index: 9999;">
<slot />
</div>
</Teleport>
</div>
</template>
The lesson: overflow: hidden and transform on an ancestor clip and scope the z-index of descendant elements. Teleport moves the modal DOM to body, escaping the parent's stacking context entirely.