frontend / react
Portals : rendre en dehors de l'arbre DOM parent
Explication
Ce que vous allez apprendre
- Comprendre pourquoi certaines propriétés CSS parentes empêchent un affichage correct
- Utiliser
createPortalpour 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.
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 de la page : un conteneur dédié, frère de #root -->
<body>
<div id="root"></div>
<div id="modale-root"></div>
</body>// 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>;
}// 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>
);
}// 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: hiddend'un ancêtre pour modales, tooltips, menus. - Un conteneur créé dynamiquement doit être ajouté et retiré du DOM manuellement (via
useEffect+ cleanup).
Exercices pratiques
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.