Vue 3 : Teleport, Suspense et Fragments

Front-end 10/04/2026 14:00:00 angularforall.com
Vue 3 Teleport Suspense Fragments Async
Vue 3 : Teleport, Suspense et Fragments

Maîtrisez Teleport, Suspense et les Fragments Vue 3 : modales accessibles, composants async, multi-root templates et patterns de production.

Les nouveautés structurelles Vue 3

Vue 3 a introduit trois fonctionnalités built-in qui résolvent des problèmes architecturaux récurrents dans les applications frontend modernes : Teleport pour rendre du contenu en dehors de l'arbre de composants, Suspense pour gérer élégamment les composants asynchrones, et Fragments pour libérer les templates de la contrainte d'un élément racine unique. Ces trois fonctionnalités travaillent souvent ensemble dans les architectures applicatives réelles.

Fonctionnalité Problème résolu Vue 2 Vue 3
Teleport Modales/tooltips hors du DOM parent Hacks CSS / portals manuels <Teleport to="body">
Suspense Attente des composants async v-if + loading state manuel <Suspense> natif
Fragments Plusieurs éléments racine Wrapper <div> obligatoire Template multi-racine natif
Ces fonctionnalités sont built-in — pas besoin d'installer une bibliothèque. Teleport, Suspense et les fragments font partie du core Vue 3 et sont disponibles dans tout projet Vue 3.

Teleport : rendre ailleurs dans le DOM

<Teleport> permet de rendre le contenu d'un composant dans un nœud DOM différent de son parent dans l'arbre Vue. Le composant garde sa logique et ses données dans son parent, mais son HTML est inséré ailleurs dans la page — typiquement directement dans <body>.

Le problème sans Teleport

<!-- ❌ Problème classique : la modal est piégée dans un container avec overflow:hidden -->
<!-- ou z-index incorrect à cause de l'arbre de stacking context CSS -->

<!-- HTML généré sans Teleport -->
<div class="page">                    <!-- position: relative -->
  <div class="card">                  <!-- overflow: hidden -->
    <div class="modal-overlay">        <!-- PIÉGÉE dans .card ! -->
      <div class="modal">...</div>
    </div>
  </div>
</div>

<!-- La modal ne peut pas couvrir toute la page car elle est -->
<!-- dans un container avec overflow:hidden ou z-index insuffisant -->
<!-- ✅ Solution avec Teleport -->

<!-- Dans le composant Vue (ici dans .card) -->
<template>
  <div class="card">
    <button @click="isOpen = true">Ouvrir la modal</button>

    <!-- Teleport "téléporte" le contenu vers body -->
    <!-- Le composant reste logiquement dans .card, mais le HTML va dans body -->
    <Teleport to="body">
      <div v-if="isOpen" class="modal-overlay">
        <div class="modal">
          <h2>Modal title</h2>
          <button @click="isOpen = false">Fermer</button>
        </div>
      </div>
    </Teleport>
  </div>
</template>
<!-- HTML généré dans le vrai DOM : -->
<!-- <body> -->
<!--   <div id="app">...</div> -->
<!--   <div class="modal-overlay">...</div> ← ici ! -->
<!-- </body> -->

Syntaxe et options de Teleport

<script setup lang="ts">
import { ref } from 'vue';

const isOpen    = ref(false);
const isDisabled = ref(false); // Désactive le teleport conditionnellement
</script>

<template>
  <!-- to : sélecteur CSS ou élément DOM de destination -->
  <Teleport to="body">
    <div v-if="isOpen">Contenu téléporté dans body</div>
  </Teleport>

  <!-- Téléporter vers un élément spécifique -->
  <Teleport to="#modal-container">
    <div>Va dans <div id="modal-container"></div></div>
  </Teleport>

  <!-- :disabled — désactive le teleport (rendu à la place normale) -->
  <!-- Utile pour désactiver le teleport en mode test ou mobile -->
  <Teleport to="body" :disabled="isDisabled">
    <div>Téléporté seulement si !isDisabled</div>
  </Teleport>

  <!-- defer (Vue 3.5+) : attend que la cible soit montée -->
  <!-- Utile si la cible est elle-même un composant Vue -->
  <Teleport to="#dynamic-target" defer>
    <div>Attend que #dynamic-target existe</div>
  </Teleport>
</template>
Réactivité conservée : Même si le HTML est téléporté ailleurs dans le DOM, le composant reste dans l'arbre Vue logique. Toutes les props, emits, provide/inject et la réactivité fonctionnent normalement. C'est uniquement le rendu DOM qui change de place.

Cas d'usage Teleport : modales et tooltips

Modal accessible avec Teleport

<!-- components/AppModal.vue — Modal complète et accessible -->
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue';

const props = defineProps<{
  modelValue: boolean;  // v-model pour l'ouverture
  title?: string;
  size?: 'sm' | 'md' | 'lg' | 'xl';
}>();

const emit = defineEmits<{
  'update:modelValue': [value: boolean];
  confirm: [];
  cancel: [];
}>();

const close = () => emit('update:modelValue', false);

// Fermeture avec Escape
const handleKeydown = (e: KeyboardEvent) => {
  if (e.key === 'Escape' && props.modelValue) close();
};

// Bloque le scroll du body quand la modal est ouverte
watch(() => props.modelValue, (isOpen) => {
  document.body.style.overflow = isOpen ? 'hidden' : '';
});

onMounted(() =>   document.addEventListener('keydown', handleKeydown));
onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
</script>

<template>
  <Teleport to="body">
    <Transition name="modal">
      <div
        v-if="modelValue"
        class="modal-backdrop"
        role="dialog"
        aria-modal="true"
        :aria-label="title"
        @click.self="close"
      >
        <div :class="['modal-content', `modal-${size ?? 'md'}`]">

          <header v-if="title || $slots.header" class="modal-header">
            <slot name="header">
              <h2 id="modal-title">{{ title }}</h2>
            </slot>
            <button
              @click="close"
              class="modal-close"
              aria-label="Fermer la modal"
            >
              &times;
            </button>
          </header>

          <main class="modal-body">
            <slot />
          </main>

          <footer v-if="$slots.footer" class="modal-footer">
            <slot name="footer" />
          </footer>

        </div>
      </div>
    </Transition>
  </Teleport>
</template>

<style scoped>
.modal-enter-active, .modal-leave-active { transition: opacity 0.2s ease; }
.modal-enter-from, .modal-leave-to       { opacity: 0; }
</style>

Tooltip avec Teleport (évite le clipping)

<!-- components/Tooltip.vue — Tooltip qui échappe aux overflow:hidden -->
<script setup lang="ts">
import { ref, computed } from 'vue';

defineProps<{ text: string; position?: 'top' | 'bottom' | 'left' | 'right' }>();

const triggerRef = ref<HTMLElement | null>(null);
const visible    = ref(false);
const coords     = ref({ x: 0, y: 0 });

const showTooltip = () => {
  if (!triggerRef.value) return;
  const rect = triggerRef.value.getBoundingClientRect();
  coords.value = { x: rect.left + rect.width / 2, y: rect.top - 8 };
  visible.value = true;
};

const hideTooltip = () => { visible.value = false; };
</script>

<template>
  <span
    ref="triggerRef"
    @mouseenter="showTooltip"
    @mouseleave="hideTooltip"
    class="tooltip-trigger"
  >
    <slot />
  </span>

  <!-- Le tooltip est rendu dans body — jamais coupé par overflow:hidden -->
  <Teleport to="body">
    <div
      v-if="visible"
      class="tooltip-bubble"
      :style="{ left: coords.x + 'px', top: coords.y + 'px' }"
      role="tooltip"
    >
      {{ text }}
    </div>
  </Teleport>
</template>

Suspense : gérer les composants async

<Suspense> est un composant built-in Vue 3 qui permet d'afficher un état de chargement (fallback) pendant qu'un composant asynchrone se charge. Il fonctionne avec les composants qui ont un setup() async ou qui utilisent defineAsyncComponent.

Composant async avec await dans setup

<!-- components/UserProfile.vue — Composant avec setup() async -->
<script setup lang="ts">
// Quand setup() est async, Vue suspend le rendu jusqu'à la résolution
// Le composant parent DOIT être wrappé dans <Suspense>

interface User { id: number; name: string; bio: string; avatar: string; }

// await directement dans setup — le composant est "async" automatiquement
const user = await fetch('/api/user/1').then(r => r.json()) as User;

// À ce stade, user est résolu — on peut l'utiliser normalement
const initials = user.name.split(' ').map(n => n[0]).join('');
</script>

<template>
  <!-- Ce template ne s'affiche que quand l'await est résolu -->
  <div class="profile">
    <img :src="user.avatar" :alt="user.name" />
    <h2>{{ user.name }}</h2>
    <p>{{ user.bio }}</p>
  </div>
</template>
<!-- Page parente avec Suspense -->
<template>
  <div class="page">
    <h1>Profil utilisateur</h1>

    <!-- Suspense wrape le composant async -->
    <Suspense>

      <!-- Slot par défaut : contenu à afficher quand résolu -->
      <UserProfile />

      <!-- Slot "fallback" : affiché pendant le chargement -->
      <template #fallback>
        <div class="skeleton">
          <div class="skeleton-avatar"></div>
          <div class="skeleton-text"></div>
          <div class="skeleton-text short"></div>
        </div>
      </template>

    </Suspense>
  </div>
</template>

Suspense avec defineAsyncComponent

<script setup lang="ts">
import { defineAsyncComponent } from 'vue';

// Chargement paresseux d'un composant lourd (code splitting)
const HeavyChart = defineAsyncComponent(() =>
  import('./HeavyChart.vue')
  // Vite génère un chunk séparé — chargé seulement quand nécessaire
);

const DataTable = defineAsyncComponent({
  loader: () => import('./DataTable.vue'),
  // loadingComponent : fallback intégré (alternative à Suspense)
  // errorComponent  : affiché en cas d'erreur de chargement
  delay: 200,   // Attend 200ms avant d'afficher le fallback (évite le flash)
  timeout: 5000, // Erreur si le chargement prend plus de 5 secondes
});
</script>

<template>
  <Suspense>
    <!-- HeavyChart est chargé de façon asynchrone -->
    <HeavyChart :data="chartData" />
    <template #fallback>
      <p>Chargement du graphique...</p>
    </template>
  </Suspense>
</template>
Suspense est expérimental (stable mais l'API peut évoluer). L'équipe Vue recommande de l'utiliser avec prudence en production et de surveiller les notes de release. Il est pleinement stable avec Nuxt 3 qui en fait un usage intensif.

Suspense avancé : erreurs et nested

Gestion des erreurs avec onErrorCaptured

<!-- components/AsyncErrorBoundary.vue — Gestion d'erreurs pour Suspense -->
<script setup lang="ts">
import { ref, onErrorCaptured } from 'vue';

const error     = ref<Error | null>(null);
const hasError  = ref(false);

// onErrorCaptured intercepte les erreurs des composants enfants
// Fonctionne parfaitement avec Suspense pour attraper les erreurs async
onErrorCaptured((err: Error) => {
  error.value   = err;
  hasError.value = true;
  return false; // Empêche la propagation de l'erreur vers le parent
});

const retry = () => {
  error.value    = null;
  hasError.value = false;
  // Force le re-montage du composant async pour retenter
};
</script>

<template>
  <!-- Affiche l'erreur ou le composant async selon l'état -->
  <div v-if="hasError" class="error-boundary">
    <p role="alert">Une erreur s'est produite : {{ error?.message }}</p>
    <button @click="retry">Réessayer</button>
  </div>

  <Suspense v-else>
    <slot /> <!-- Le composant async vient ici -->
    <template #fallback>
      <slot name="loading">
        <p>Chargement...</p>
      </slot>
    </template>
  </Suspense>
</template>
<!-- Utilisation du composant ErrorBoundary -->
<template>
  <AsyncErrorBoundary>
    <UserDashboard /> <!-- Composant async susceptible d'échouer -->

    <template #loading>
      <DashboardSkeleton /> <!-- Skeleton personnalisé -->
    </template>
  </AsyncErrorBoundary>
</template>

Suspense imbriqués

<template>
  <!-- Suspense externe : attend le layout principal -->
  <Suspense>
    <template #fallback>
      <PageSkeleton /> <!-- Skeleton de la page entière -->
    </template>

    <div class="page-layout">
      <!-- Suspense interne : attend le sidebar (indépendant du contenu) -->
      <Suspense>
        <template #fallback>
          <SidebarSkeleton />
        </template>
        <AsyncSidebar /> <!-- Se résout indépendamment -->
      </Suspense>

      <main>
        <AsyncContent /> <!-- Suspend le Suspense parent -->
      </main>
    </div>
  </Suspense>
</template>

Événements Suspense

<template>
  <Suspense
    @resolve="onResolved"   <!-- Déclenché quand tous les async sont résolus -->
    @pending="onPending"    <!-- Déclenché quand un nouveau async commence -->
    @fallback="onFallback"  <!-- Déclenché quand le fallback s'affiche -->
  >
    <AsyncComponent />
    <template #fallback><Spinner /></template>
  </Suspense>
</template>

<script setup lang="ts">
const onResolved = () => console.log('Composant async prêt !');
const onPending  = () => console.log('Chargement en cours...');
const onFallback = () => console.log('Fallback affiché');
</script>

Fragments : plusieurs éléments racine

Vue 2 imposait un seul élément racine par template. En Vue 3, les fragments permettent d'avoir plusieurs éléments racine directement dans le template — sans wrapper <div> superflu. C'est une amélioration simple mais qui a un impact réel sur la sémantique HTML et l'accessibilité.

Avant / après les fragments

<!-- ❌ Vue 2 — wrapper div obligatoire -->
<template>
  <div> <!-- Wrapper inutile, pollue le DOM -->
    <header>...</header>
    <main>...</main>
    <footer>...</footer>
  </div>
</template>
<!-- ✅ Vue 3 — plusieurs éléments racine natifs (fragments) -->
<template>
  <header>...</header>
  <main>...</main>
  <footer>...</footer>
  <!-- Aucun wrapper — HTML sémantique propre -->
</template>

Cas d'usage concrets des fragments

<!-- Composant TableRow — doit retourner des <td> directement sans <tr> wrapper -->
<!-- Impossible en Vue 2 sans hack -->
<!-- components/TableRow.vue -->
<template>
  <!-- Fragment : plusieurs <td> comme racine (pas de <tr> wrapper ici) -->
  <td>{{ user.id }}</td>
  <td>{{ user.name }}</td>
  <td>{{ user.email }}</td>
  <td>
    <button @click="$emit('edit', user.id)">Éditer</button>
  </td>
</template>

<script setup lang="ts">
defineProps<{ user: { id: number; name: string; email: string } }>();
defineEmits<{ edit: [id: number] }>();
</script>
<!-- Utilisation dans un tableau -->
<template>
  <table>
    <thead>
      <tr><th>ID</th><th>Nom</th><th>Email</th><th>Actions</th></tr>
    </thead>
    <tbody>
      <tr v-for="user in users" :key="user.id">
        <!-- TableRow retourne plusieurs <td> sans <tr> wrapper ✅ -->
        <TableRow :user="user" @edit="handleEdit" />
      </tr>
    </tbody>
  </table>
</template>
<!-- Fragments avec v-for -->
<template>
  <!-- Grouper plusieurs éléments par itération sans wrapper -->
  <template v-for="section in sections" :key="section.id">
    <h2>{{ section.title }}</h2>
    <p>{{ section.intro }}</p>
    <ul>
      <li v-for="item in section.items" :key="item.id">{{ item.name }}</li>
    </ul>
    <hr v-if="section.showDivider" />
  </template>
  <!-- Génère : h2, p, ul, h2, p, ul... sans div wrapper à chaque itération -->
</template>
Attention aux attrs hérités : Avec un fragment (plusieurs racines), Vue ne sait pas sur quel élément appliquer les attributs hérités du parent (class, style, event listeners). Utilisez inheritAttrs: false et v-bind="$attrs" manuellement sur l'élément souhaité si nécessaire.

Combiner Teleport, Suspense et Fragments

En production, ces trois fonctionnalités s'utilisent souvent ensemble pour construire des systèmes de notifications, des panneaux de confirmation ou des chargements page entière.

<!-- composables/useNotification.ts — Système de notifications global -->
import { reactive } from 'vue';

interface Notification {
  id: number;
  type: 'success' | 'error' | 'warning' | 'info';
  message: string;
  duration: number;
}

const notifications = reactive<Notification[]>([]);
let nextId = 0;

export function useNotification() {
  const add = (notification: Omit<Notification, 'id'>) => {
    const id = ++nextId;
    notifications.push({ ...notification, id });

    // Suppression automatique après duration ms
    setTimeout(() => {
      const index = notifications.findIndex(n => n.id === id);
      if (index !== -1) notifications.splice(index, 1);
    }, notification.duration);
  };

  const remove = (id: number) => {
    const index = notifications.findIndex(n => n.id === id);
    if (index !== -1) notifications.splice(index, 1);
  };

  const success = (message: string, duration = 3000) =>
    add({ type: 'success', message, duration });

  const error = (message: string, duration = 5000) =>
    add({ type: 'error', message, duration });

  return { notifications, add, remove, success, error };
}
<!-- components/NotificationStack.vue — Toasts téléportés dans body -->
<script setup lang="ts">
import { useNotification } from '@/composables/useNotification';
const { notifications, remove } = useNotification();
</script>

<template>
  <!-- Teleport : les notifications sont toujours au bon z-index -->
  <Teleport to="body">
    <!-- Fragment : liste de toasts sans wrapper -->
    <TransitionGroup name="toast" tag="div" class="toast-stack">
      <div
        v-for="notif in notifications"
        :key="notif.id"
        :class="['toast', `toast-${notif.type}`]"
        role="alert"
        aria-live="polite"
      >
        <!-- Fragment dans le toast : icône + message sans wrapper -->
        <span class="toast-icon">{{ notif.type === 'success' ? '✓' : '!' }}</span>
        <p class="toast-message">{{ notif.message }}</p>
        <button @click="remove(notif.id)" aria-label="Fermer">×</button>
      </div>
    </TransitionGroup>
  </Teleport>
</template>
<!-- app/Dashboard.vue — Page avec Suspense + notifications -->
<script setup lang="ts">
import { defineAsyncComponent } from 'vue';
import { useNotification } from '@/composables/useNotification';

const { success, error } = useNotification();

// Chargement asynchrone des composants lourds
const StatsPanel  = defineAsyncComponent(() => import('./StatsPanel.vue'));
const RecentOrders = defineAsyncComponent(() => import('./RecentOrders.vue'));

const handleAction = async () => {
  try {
    await doSomething();
    success('Action réussie !');
  } catch {
    error('Une erreur est survenue');
  }
};
</script>

<template>
  <!-- Fragment : deux sections sans wrapper div -->
  <section class="stats">
    <Suspense>
      <StatsPanel />
      <template #fallback><StatsSkeleton /></template>
    </Suspense>
  </section>

  <section class="orders">
    <Suspense>
      <RecentOrders />
      <template #fallback><OrdersSkeleton /></template>
    </Suspense>
  </section>

  <!-- NotificationStack utilise Teleport en interne -->
  <NotificationStack />
</template>

Performances et accessibilité

Teleport et performances

<!-- ✅ Bonnes pratiques Teleport -->

<!-- 1. Utiliser v-if pour ne monter le composant que si nécessaire -->
<!-- Évite de monter des DOM nodes inutiles au chargement -->
<Teleport to="body">
  <AppModal v-if="modalOpen" />  <!-- Monté seulement si ouvert ✅ -->
</Teleport>

<!-- 2. Plusieurs Teleport vers la même destination sont cumulatifs -->
<!-- Ils s'ajoutent dans l'ordre des composants -->
<Teleport to="#notifications"><Toast :msg="msg1" /></Teleport>
<Teleport to="#notifications"><Toast :msg="msg2" /></Teleport>
<!-- → #notifications contient les deux toasts dans l'ordre -->

Accessibilité avec Teleport

<!-- Les modales accessibles ont besoin de : -->
<!-- 1. role="dialog" et aria-modal="true" -->
<!-- 2. aria-labelledby pointant vers le titre -->
<!-- 3. Focus trap (garder le focus dans la modal) -->
<!-- 4. Fermeture par Escape -->
<!-- 5. Retour du focus au déclencheur après fermeture -->

<Teleport to="body">
  <div
    v-if="isOpen"
    role="dialog"
    aria-modal="true"
    aria-labelledby="modal-heading"
    @keydown.esc="close"
  >
    <h2 id="modal-heading">{{ title }}</h2>
    <!-- Le contenu de la modal -->
  </div>
</Teleport>

Suspense et Core Web Vitals

<!-- Suspense améliore le LCP (Largest Contentful Paint) en affichant -->
<!-- immédiatement un skeleton au lieu d'un layout cassé -->

<!-- Pattern skeleton screen -- meilleur que spinner -->
<Suspense>
  <ArticleContent />

  <template #fallback>
    <!-- Skeleton qui imite la structure réelle de l'article -->
    <div class="article-skeleton" aria-busy="true" aria-label="Chargement de l'article">
      <div class="skeleton-line skeleton-title"></div>
      <div class="skeleton-line"></div>
      <div class="skeleton-line"></div>
      <div class="skeleton-line skeleton-short"></div>
    </div>
  </template>
</Suspense>

<!-- aria-busy="true" informe les lecteurs d'écran que le contenu charge -->

Checklist Teleport, Suspense, Fragments

Teleport

  • Utiliser pour les modales, drawers, tooltips, toasts — tout ce qui dépasse son container CSS
  • to="body" est la destination la plus courante
  • Combiner avec v-if pour éviter les nœuds DOM inutiles
  • Ajouter role="dialog" et aria-modal="true" pour l'accessibilité
  • Gérer la fermeture par Escape et le retour de focus après fermeture

Suspense

  • Wrapper les composants avec await dans leur setup()
  • Toujours fournir un #fallback significatif (skeleton > spinner)
  • Combiner avec onErrorCaptured pour gérer les erreurs async
  • Utiliser les events @resolve et @pending pour le tracking
  • Préférer defineAsyncComponent pour le code splitting des composants lourds

Fragments

  • Supprimer les <div> wrappers inutiles dans les templates
  • Utiliser pour les composants <td>, <li>, <dt/dd> sans wrapper
  • Avec v-for sur <template> pour grouper plusieurs éléments par itération
  • Si des attributs hérités posent problème : inheritAttrs: false + v-bind="$attrs"
Ces fonctionnalités sont plus complémentaires que concurrentes. Dans un système de notification : Teleport place les toasts dans body, les Fragments évitent un wrapper div autour de chaque toast, et Suspense gère l'état de chargement des données qui alimentent les notifications. Les comprendre ensemble vous donne une vision complète de l'architecture Vue 3 moderne.

Teleport, Suspense et les Fragments sont les trois fonctionnalités built-in Vue 3 les plus méconnues des débutants, mais les plus appréciées des équipes expérimentées. Ils éliminent des classes entières de hacks CSS (z-index, overflow), simplifient la gestion des états de chargement asynchrones, et produisent un HTML sémantiquement correct sans wrappers superficiels. Les adopter dès le début d'un projet, c'est éviter des refactorisations douloureuses plus tard.

Partager