Skip to main content

Teleport and Suspense — moving where things render, and waiting on async

The provide and inject in chapter 12 detached the passing of values from the component hierarchy. The two features in this chapter detach something else. Teleport moves where content renders to another place in the DOM tree, and Suspense gathers what is shown until async work finishes into one place. Both take something normally bound to the shape of the component tree and move it outside.

What you'll learn in this chapter​

  • What is left at the original position once Teleport has moved the content
  • When defer is needed, and when its resolution runs
  • State what does not change even though the DOM position does
  • The conditions under which Suspense's fallback appears, and when it does not
  • The condition for returning to the fallback on a later render
  • Why the inside of the fallback is treated as outside the boundary

Moving where things render to another place in the DOM​

Modals and tooltips are things you want to write inside a component while keeping them out of an ancestor's overflow and z-index in the DOM. <Teleport> moves only where the content renders.

components/ModalHost.vue
<script setup lang="ts">
import { ref } from 'vue'

const open = ref(false)
</script>

<template>
<button type="button" @click="open = true">Open</button>

<Teleport to="body">
<div v-if="open" class="overlay">
<div class="dialog">
<p>Body</p>
<button type="button" @click="open = false">Close</button>
</div>
</div>
</Teleport>
</template>

to takes a CSS selector or a DOM element. Point several <Teleport>s at the same target and they stack in mount order.

The original position is not left empty. Two nodes go in, and their kind varies with the build.

How it is renderedOriginal positionTarget side
Fresh client render (development build)Two comments (<!--teleport start--> / <!--teleport end-->)Two empty text nodes
Fresh client render (production build)Two empty text nodesTwo empty text nodes
HydrationTwo commentsTwo comments (reusing what the server emitted)

The third row assumes the server's content was inserted into the target. Hydrate without inserting it and the target side gets empty text nodes. The development build reports Hydration children mismatch, while the production build says only Hydration completed but contains mismatches.. Write code that counts DOM nodes directly, or a snapshot test, and this difference makes the result vary by build and by rendering path.

Bringing it back with disabled​

While disabled is set, the content renders at the original position.

<Teleport :disabled="isMobile" to="body">
<div class="dialog">…</div>
</Teleport>

When the destination switches, the element is moved rather than rebuilt. The same node travels, so state held by the element, such as a partially typed value, survives (what happens to focus is browser-dependent).

disabled has a second effect. It suppresses the warning about a target not being found. The warning when one is not found is this.

[Vue warn]: Failed to locate Teleport target with selector "#not-found".
Note the target element must exist before the component is mounted ...

With disabled set, that warning never appears and it renders at the original position. Using disabled for switching means you will not notice a misspelt target.

When Vue renders the target​

As the warning says, the target has to exist before the component is mounted. That is why pointing outside the app, as with to="body", is the basic approach. Point at a target inside the same app and resolution fails at a moment when it has not been rendered yet.

From Vue 3.5 onwards, defer gets around this.

components/DeferredHost.vue
<template>
<Teleport defer to="#sidebar-slot">
<p class="badge">To sidebar</p>
</Teleport>

<aside id="sidebar-slot" />
</template>

defer postpones resolving the target. In the implementation it is queued with queuePostRenderEffect, so it runs once that mount (or update) has finished rendering everything. Its reach is targets that appear within the same render; it will not wait for an element that shows up a second later.

Where it runs sits among the hooks from chapter 7. Both go into the post-flush queue, so the order they were queued in decides. It is before the onMounted of the component that wrote the Teleport, but the onMounted of a child placed before the Teleport runs before the resolution. In an arrangement where a child component renders the target, that child's onMounted cannot see the content yet.

The DOM moves but the hierarchy does not​

Only the position in the DOM moves. The component hierarchy is unchanged, so all of the following behave exactly as they would at the original position.

  • provide and inject from chapter 12. A value an ancestor provided reaches the teleported descendants
  • Template expressions reading the parent's scope, and events aimed at the parent
  • <style scoped> from chapter 1. The content receives the original component's scope attribute. But selectors that depend on DOM ancestry stop matching — a descendant selector such as .host .dialog no longer matches once the content leaves .host

Routes that never unmount need care. A Teleport inside a component cached and hidden by <KeepAlive> leaves its content in the target. Only onDeactivated from chapter 7 runs, so unless you close the modal there, it stays on screen.

Server-side rendering handles it differently. The content does not go into the main HTML. Per-target HTML is gathered in the teleports of the context passed as renderToString's second argument.

const ctx = {}
const html = await renderToString(app, ctx)
// all that remains in html is <!--teleport start--> and <!--teleport end-->
// the content HTML is in ctx.teleports['#modal-root']

These server-rendered comments appear in production builds too, in contrast to the client-side original position, which becomes empty text nodes in production.

With disabled set, it is the other way round. The content goes into the main HTML and only the anchors go into teleports. If you use disabled for responsive switching, the server-side rendering destination switches with it.

Where this gets inserted into the final HTML is the framework's job. As with the SSR in chapter 9, building it yourself means writing the insertion yourself.

Gathering the wait for async work​

<Suspense> waits on async work among its descendants and takes charge of what is shown in the meantime, in one place. It is an experimental feature.

Experimental feature

<Suspense> is experimental, and the official guide writes that it is "not guaranteed to reach stable status and the API may change". The development build emits <Suspense> is an experimental feature and its API will likely change. (that string is absent from production builds). It goes through console.info rather than console.warn, and only once across apps, so an environment collecting warnings will miss it.

Two kinds of dependency can be waited on: an async setup(), and async components. A top-level await in <script setup> automatically makes the component the former.

components/UserProfile.vue
<script setup lang="ts">
interface User {
name: string
email: string
}

const props = defineProps<{ id: number }>()

const res = await fetch(`/api/users/${props.id}`)
const user = (await res.json()) as User
</script>

<template>
<section class="profile">
<h2>{{ user.name }}</h2>
<p>{{ user.email }}</p>
</section>
</template>

The boundary side has two slots. Each takes only a single direct child.

components/UserPanel.vue
<script setup lang="ts">
import UserProfile from './UserProfile.vue'
</script>

<template>
<Suspense>
<UserProfile :id="1" />

<template #fallback>
<p class="loading">Loading</p>
</template>
</Suspense>
</template>

Write two children and you get the warning <Suspense> slots expect a single root node., and neither child renders. A slot without a single root falls to a comment node, so nothing appears on screen. The warning is development-only, so production goes blank without a word.

When the fallback appears​

On the first render, the contents of #default are first rendered inside a detached <div>. Hitting an async dependency there puts it into the pending state and #fallback goes into the real DOM. Once every dependency resolves, they swap.

There are three events. Lining the measured order up with the conditions gives this.

SituationEvents emittedWhat renders
First render, async dependencies presentpending → fallback → (after resolution) resolvefallback → default
First render, no async dependenciesresolve onlydefault

resolve fires even with zero dependencies. Using it as "the signal that loading finished" means it also fires on routes with no async dependency at all. Distinguish by whether pending fired.

Hydration sits outside this table. It takes over the #default the server rendered, so pending and fallback do not appear even with async dependencies outstanding. Only resolve fires once they resolve.

It does not go back after the first time​

A boundary that has once resolved returns to pending only when the root node of #default is swapped. The check is isSameVNodeType, so it looks at both the type and the key. Writing <Profile :key="id" /> to force a refetch counts as a swap even with the same type.

Conversely, if the root has the same type and the same key, no new async dependency, however deep, brings it back. No events fire either. That part is empty while it waits and appears once resolved.

How it looks while the root type changes is decided by timeout. Three measured cases.

timeoutWhat appears during the swap
A positive value (50)The previous content stays for 50ms, then the fallback
0Switches to the fallback immediately
Omitted / less than 0The previous content stays. The fallback never appears

The implementation branches only on timeout > 0 and timeout === 0, so everything else falls into the third row. The pending event fires in every case; it is the fallback that does not. The default is "keep showing the previous content while waiting", so state timeout explicitly when you want the switch to be visible.

timeout is read once, when the boundary is created. Passing a ref to :timeout and changing it later has no effect on that boundary.

Inside the fallback is outside the boundary​

The #fallback tree is rendered without the boundary's context. The implementation passes null for the parent Suspense when patching the fallback, and a comment says "fallback tree will not have suspense context". That settles the following.

  • An async dependency inside the fallback is not waited on. Put a component with a top-level await there and you get setup function returned a promise, but no <Suspense> boundary was found, and it does not render
  • A Teleport inside the fallback resolves immediately. A Teleport inside #default, by contrast, postpones resolution even without defer

The condition for the latter is "it was mounted while the nearest boundary was pending". That is all the implementation looks at, so a Teleport that appears through a v-if after the boundary resolved does not wait. With nested boundaries where only the inner one has resolved, Teleports below it do not wait either. State defer explicitly when pointing at a target that appears within the same render.

Errors are caught outside the boundary​

<Suspense> has no error handling of its own. When an async setup() rejects, it reaches the first onErrorCaptured found by walking up from the failing component. The official guide's advice is to put it above the boundary, but an intermediate wrapper inside #default catches it too — the boundary plays no part in the decision.

onErrorCaptured((err) => {
message.value = err instanceof Error ? err.message : String(err)
return false
})

What is displayed afterwards needs care. Measured, the boundary resolves after the error. The fallback disappears and the #default whose setup was interrupted renders. No value went in, so a template referring to user.name renders blank or throws at runtime. What to show on error is something you decide yourself, such as switching with a v-if outside the boundary.

The relationship with async components​

A component built with defineAsyncComponent is suspensible by default. Its own loadingComponent / delay / timeout go unused in exactly the two cases the implementation states: being under a boundary while suspensible, and server-side rendering. With no boundary, its own loading appears even at the default setting.

const Lazy = defineAsyncComponent({
loader: () => import('./Heavy.vue'),
suspensible: false, // when you want to show your own loading
loadingComponent: Spinner, // unused when suspensible under a boundary
delay: 0, // omit it and nothing appears for 200ms
})

Set suspensible: false and the boundary stops waiting for that component. With no other outstanding dependency it resolves immediately, and the component shows its own loadingComponent.

errorComponent alone is handled differently. It is used on load failure even while suspensible. The official guide writes "loading, error, delay and timeout options will be ignored", but the only path left in the implementation is the error one.

A nested <Suspense> has a setting by the same name. That one is for making the inner boundary a dependency of the outer one, which is a different meaning.

<Suspense>
<Outer>
<Suspense suspensible>
<Inner />
</Suspense>
</Outer>
</Suspense>

Without suspensible, the inner boundary looks like a synchronous component to the outer one. The inner shows its own fallback and the outer resolves immediately. With it, the outer waits too, and only the outer fallback reaches the DOM (the inner still emits events). Choose by which loading you want visible.

It only takes effect when the inner boundary is created while the outer is still pending. If the inner appears through a v-if after the outer resolved, the outer does not wait even with suspensible written. The check is "not false", so a suspensible="false" missing its : is a string and opts in.

The order when combining them​

There is a fixed nesting order when combining with Transition / KeepAlive / RouterView. This is the shape the official guide gives.

<RouterView v-slot="{ Component }">
<template v-if="Component">
<Transition mode="out-in">
<KeepAlive>
<Suspense>
<component :is="Component" />
<template #fallback>Loading</template>
</Suspense>
</KeepAlive>
</Transition>
</template>
</RouterView>

Drop whatever you do not need. Note that Vue Router's lazy loading (a dynamic import in the route definition) does not currently trigger <Suspense>. The official guide describes it as the current state, "currently they will not trigger". It is a different mechanism from async components. Put an async component inside the route and that does reach the boundary as usual. Routing is covered in chapter 15.

Summary​

  • <Teleport> moves only where the content renders to another place in the DOM. to takes a selector or an element, and several Teleports aimed at the same target stack in mount order
  • Two nodes go into the original position and into the target. Their kind varies with the rendering path — for a fresh client render the original position gets comments in a development build and empty text in production, while the target gets empty text either way. Under hydration both are comments if the server's content was inserted into the target
  • While disabled is set it renders at the original position, and switching moves the element rather than rebuilding it. disabled also suppresses the warning about a missing target, so a misspelling goes unnoticed
  • The target must exist before mount. Use defer (3.5+) for a target the same app renders. Its reach is as far as targets appearing within the same render, and resolution happens in the order it was queued in the post-flush queue. It is before the onMounted of the side that wrote the Teleport, but the onMounted of a child placed before the Teleport runs first
  • Only the DOM position moves, so provide / inject, the parent's scope, and scope attributes are as they would be at the original position. But descendant selectors such as .host .dialog stop matching. Cached and hidden by <KeepAlive>, the content stays in the target
  • Under server-side rendering the content does not go into the main HTML but into the teleports of the context passed to renderToString. The comments left in the main HTML appear in production builds too. With disabled set it is reversed and the content goes into the main HTML
  • <Suspense> is experimental. The notice appears in development builds only, through console.info rather than console.warn, and only once
  • Two kinds of dependency can be waited on: an async setup() and an async component. A top-level await in <script setup> makes the former
  • Each slot takes only a single direct child. Write two and neither renders. The warning is development-only, so production goes blank without a word
  • #default is first rendered inside a detached <div>. Hitting an async dependency gives pending → fallback, and resolution gives resolve. resolve fires even with zero dependencies
  • A resolved boundary returns to pending only when the root node is swapped. The check covers both the type and the key, so re-keying counts as a swap. A new dependency deep inside does not bring it back
  • During a swap, it switches to the fallback only when timeout is 0 (immediately) or a positive value (after that long). Omitted or less than 0, the previous content stays and the fallback never appears. The value is read once when the boundary is created, so changing it later has no effect
  • The #fallback tree has no boundary context. A component with a top-level await there warns and does not render, while defineAsyncComponent loads and renders on its own with no warning. Teleports inside it resolve immediately. A Teleport inside #default waits without defer only when it was mounted while the nearest boundary was pending
  • Hydration is a separate path. It takes over the server-rendered #default, so pending and fallback do not appear with dependencies outstanding, and only resolve fires on resolution
  • Errors reach the first onErrorCaptured found by walking up from the failing component. The boundary plays no part, so placing it inside #default catches it there. The boundary resolves after the error, rendering a #default with no values in it
  • An async component's loadingComponent / delay / timeout go unused in exactly two cases: being under a boundary while suspensible, and server-side rendering. With no boundary they are used even at the default. errorComponent is used either way. If you show your own loading, do not omit delay — the default 200ms renders nothing
  • suspensible on a nested <Suspense> is the setting that makes the inner a dependency of the outer, and it only applies when the inner is created while the outer is still pending. The check is "not false", so a suspensible="false" missing its : opts in
Related references