El árbol de React no es el árbol del DOM
Mi modal se pinta en otro lado del DOM. ¿Por qué el click sigue burbujeando al padre?
Al terminar vas a poder explicar
- Explicar por qué un evento burbujea por el árbol de React y no por el del DOM
- Predecir si un componente dentro de un portal ve el contexto de su padre
- Elegir cuándo un portal es la solución y cuándo es un parche
Hasta acá dimos por sentado que el árbol de Fibers y el árbol del documento tienen la misma forma. Casi siempre la tienen. Los portales existen para romper esa correspondencia a propósito — y entender qué se rompe y qué no es la lección entera.
El problema que resuelven
Un modal que vive adentro de un contenedor con overflow: hidden queda cortado. Un tooltip adentro de un contenedor con transform hereda un contexto de apilamiento y ningún z-index lo salva. Son problemas de CSS, no de React, y no se arreglan desde React… salvo sacando el nodo de ahí.
1import { createPortal } from "react-dom";2 3function Modal({ children }) {4 return createPortal(5 <div className="modal">{children}</div>,6 document.body, // ← se pinta acá7 );8}Lo que cambia y lo que no
Un portal cambia una sola cosa: en qué nodo del documento se inserta la salida. En el árbol de React, el componente sigue exactamente donde lo escribiste.
Y como todo en React se apoya en el árbol de Fibers, todo lo demás sigue igual:
- El contexto se lee normal. La pila de proveedores se empuja al bajar por el árbol de Fibers, y el portal está en esa rama.
- Los eventos burbujean por el árbol de React, no por el del documento. Un click adentro del modal llega al
onClickdel componente padre aunque en el DOM estén en ramas separadas. - El estado y los efectos funcionan igual: es el mismo Fiber de siempre.
- Los Error Boundaries lo cubren, porque el unwinding sube por
return.
Un portal: dos árboles distintos
El nodo se pinta en otro lugar del DOM, pero el evento sigue subiendo por el árbol de React.
Código
1let disparar = null;2function accion() { if (disparar) disparar(); }3 4const { createPortal } = ReactDOM;5 6function Modal({ children, alCerrar }) {7 return createPortal(8 <div style={{ border: "1px dashed #b78cff", padding: 12, marginTop: 8 }}>9 <p>Estoy fuera del div del padre, pero adentro del árbol de React.</p>10 {children}11 <button onClick={alCerrar}>cerrar</button>12 </div>,13 document.body,14 );15}16 17function App() {18 const [abierto, setAbierto] = useState(false);19 useEffect(() => { disparar = () => setAbierto(true); });20 21 return (22 <div23 onClick={() => console.log("el click burbujeó hasta el padre en React")}24 style={{ border: "1px solid #392f4c", padding: 12 }}25 >26 <p>Este div es el padre en el árbol de React.</p>27 <button onClick={() => disparar()}>abrir el modal</button>28 {abierto && <Modal alCerrar={() => setAbierto(false)} />}29 </div>30 );31}Corriendo de verdad
bajando React 19.2.0 de esm.sh…
Todo lo que imprimas con console.log aparece acá. Los commits los reporta el Profiler de React de verdad.
Apretá «Abrir el modal» y después el botón de adentro. Mirá el registro: el click llega al onClick del div padre, aunque el modal esté colgando de document.body. Ese es el árbol de React mandando sobre el del documento.
La consecuencia práctica más molesta
1// ✗ Cierra el modal al hacer click adentro del modal.2<div onClick={cerrar}>3 <Modal>…</Modal> {/* el click de adentro burbujea hasta acá */}4</div>5 6// ✓ Cortar el burbujeo en el propio portal.7function Modal({ children, alCerrar }) {8 return createPortal(9 <div className="fondo" onClick={alCerrar}>10 <div className="panel" onClick={e => e.stopPropagation()}>11 {children}12 </div>13 </div>,14 document.body,15 );16}Cuándo NO usar un portal
Desde que existe <dialog> con showModal(), y con popover ganando soporte, el navegador resuelve nativamente lo que antes exigía un portal: la capa superior, el foco atrapado y el cierre con Escape.
El portal sigue siendo la respuesta cuando necesitás escapar de un overflow: hidden o de un contexto de apilamiento y no podés usar la capa superior del navegador. Para un modal común, probá primero <dialog>.
Antes de seguir · predecí
Un componente dentro de un portal llama a useContext(Tema). El Provider está en la raíz de la app. ¿Qué lee?
Lo que te llevás
Un portal cambia dónde se pinta un nodo y nada más. El contexto, los eventos, el estado y los Error Boundaries siguen el árbol de React. Ese burbujeo es su mayor ventaja y su trampa más común.
Antes de marcarla, comprobá
- ¿Podés explicar explicar por qué un evento burbujea por el árbol de React y no por el del DOM?
- ¿Podés explicar predecir si un componente dentro de un portal ve el contexto de su padre?
- ¿Podés explicar elegir cuándo un portal es la solución y cuándo es un parche?