Retour au cours

frontend / react

Portals : rendre en dehors de l'arbre DOM parent

Leçon 261 exercice

Explication

Ce que vous allez apprendre

  • Comprendre pourquoi certaines propriétés CSS parentes empêchent un affichage correct
  • Utiliser createPortal pour rendre du JSX dans un nœud DOM différent
  • Vérifier que Context et propagation d'événements continuent de suivre l'arbre React, pas le DOM
  • Créer et nettoyer proprement un conteneur DOM dynamique pour un portal
  • Reconnaître les cas d'usage typiques d'un portal (modale, tooltip, notification)

Dans quel contexte ?

La modale de paiement dans src/components/CheckoutModal.jsx s'affiche coupée en bas de l'écran, invisible pour la moitié de son contenu. En inspectant le DOM, elle est rendue à l'intérieur d'un conteneur .card avec overflow: hidden et position: relative, hérités d'un composant parent totalement indépendant. Plutôt que de traquer et modifier chaque parent susceptible de poser ce problème, un createPortal qui rend la modale directement dans un #modale-root au niveau du <body> règle le problème une bonne fois pour toutes.

Étape 1 : le problème posé par certaines propriétés CSS

Certaines propriétés CSS posées sur un élément parent, comme overflow: hidden, affectent inévitablement tous ses descendants dans le DOM, même ceux qui voudraient s'en affranchir visuellement.

Étape 2 : un cas classique, la modale rognée

C'est un problème classique pour une modale : si elle est rendue dans un conteneur avec overflow: hidden, elle peut se retrouver rognée, alors qu'elle doit s'afficher par-dessus tout.

Étape 3 : la solution, un portal

Un portal permet de rendre visuellement du JSX à un endroit différent du DOM réel de la page, tout en gardant ce contenu intégré dans l'arbre de composants React d'origine.

Étape 4 : comment createPortal fonctionne

createPortal(jsx, noeudDOM) prend deux arguments : ce qu'il faut afficher, et le noeud DOM cible où l'injecter réellement.

Étape 5 : ce qui continue de fonctionner normalement

Voici le point le plus important : bien que le rendu DOM soit physiquement déplacé, l'arbre de composants React reste inchangé. Le contexte continue d'être hérité normalement à travers le portal.

Étape 6 : la propagation des événements

Surtout, la propagation des événements synthétiques continue de suivre l'arbre de COMPOSANTS plutôt que la position réelle dans le DOM.

Un point de vigilance

Il reste un détail à ne pas oublier : un conteneur DOM créé dynamiquement doit être ajouté ET retiré explicitement au démontage, via useEffect et son cleanup.

Piège fréquent

Créer un conteneur DOM dynamique avec document.createElement sans le retirer au démontage (document.body.removeChild dans le cleanup d'un useEffect) laisse un nœud orphelin dans le DOM à chaque fermeture/réouverture, une fuite silencieuse qui s'accumule au fil de la session.

Ce qui est physiquement déplacéCe qui reste inchangé
Le nœud DOM réel (rendu ailleurs dans la page)L'arbre de composants React (parent/enfant logique)
L'héritage de Context
La propagation des événements synthétiques

Et ensuite ?

Une fois les portals compris, la prochaine étape approfondit un piège fréquent du data fetching avec Suspense : les waterfalls de requêtes.

Commandes & code

Portals : rendre en dehors de l'arbre DOM parent

Injecter du JSX dans un noeud DOM différent, tout en gardant l'arbre de composants React (contexte, événements) intact.

jsx
import { createPortal } from "react-dom";

// Cas d'usage typique : une modale qui doit échapper à un parent avec overflow:hidden/z-index limité
function Modale({ enfant, onFermer }) {
    return createPortal(
        <div className="modale-overlay" onClick={onFermer}>
            <div className="modale-contenu" onClick={(e) => e.stopPropagation()}>
                {enfant}
            </div>
        </div>,
        document.getElementById("modale-root") // noeud DOM CIBLE, hors de l'arbre React parent
    );
}
html
<!-- HTML de la page : un conteneur dédié, frère de #root -->
<body>
    <div id="root"></div>
    <div id="modale-root"></div>
</body>
jsx
// Le composant reste un enfant React NORMAL malgré le portal :
// contexte, propagation d'événements React (synthétique), et cycle de vie fonctionnent comme d'habitude
const ThemeContext = createContext("clair");

function App() {
    const [ouverte, setOuverte] = useState(false);

    return (
        <ThemeContext.Provider value="sombre">
            <button onClick={() => setOuverte(true)}>Ouvrir</button>
            {ouverte && (
                <Modale onFermer={() => setOuverte(false)}>
                    <ContenuModale /> {/* peut lire ThemeContext malgré le DOM ailleurs */}
                </Modale>
            )}
        </ThemeContext.Provider>
    );
}

function ContenuModale() {
    const theme = useContext(ThemeContext); // "sombre" : hérité par l'arbre React, pas le DOM
    return <p className={theme}>Contenu de la modale</p>;
}
jsx
// Propagation d'événements : un clic DANS le portal remonte quand même dans l'arbre REACT parent,
// même si le noeud DOM réel est physiquement ailleurs dans le document
function ParentAvecLog() {
    return (
        <div onClick={() => console.log("Clic capté par le parent React")}>
            <Modale onFermer={() => {}}>
                <button>Cliquer ici déclenche aussi le log du parent</button>
            </Modale>
        </div>
    );
}
jsx
// Portal créé dynamiquement, sans noeud statique dans le HTML de base
function TooltipPortal({ enfant }) {
    const [conteneur] = useState(() => {
        const div = document.createElement("div");
        div.className = "tooltip-portal";
        return div;
    });

    useEffect(() => {
        document.body.appendChild(conteneur);
        return () => document.body.removeChild(conteneur); // nettoyage impératif obligatoire
    }, [conteneur]);

    return createPortal(enfant, conteneur);
}

Résumé

  • createPortal(jsx, noeudDOM) rend visuellement ailleurs dans le DOM, tout en restant dans l'arbre de composants React.
  • Contexte, propagation d'événements synthétiques et cycle de vie traversent le portal normalement.
  • Les portals résolvent les problèmes de z-index/overflow: hidden d'un ancêtre pour modales, tooltips, menus.
  • Un conteneur créé dynamiquement doit être ajouté et retiré du DOM manuellement (via useEffect + cleanup).

Exercices pratiques

1 disponible
1

Mission : sortir CheckoutModal de sa prison à overflow:hidden

Objectif : Corriger une modale rognée avec createPortal, tout en expliquant ce qui reste inchangé dans l'arbre React malgré le déplacement DOM.

Contexte

CheckoutModal.jsx est rendue directement à l'intérieur d'un conteneur .card (avec overflow: hidden; position: relative;) hérité d'un composant parent totalement indépendant du panier. Résultat : la moitié inférieure de la modale de paiement est invisible. Un #modale-root, frère de #root dans le HTML, existe déjà mais n'est pas utilisé. La modale a aussi besoin de lire ThemeContext fourni par un Provider situé au-dessus de .card, et elle doit se fermer au clic sur l'overlay SANS se fermer quand on clique à l'intérieur du contenu de paiement lui-même.

Résoudre l’exercice →