React Portals — Renderiza elementos hijos en un nodo DOM fuera de la jerarquía DOM del componente padre
Receta
import { createPortal } from "react-dom";
function Modal({ isOpen, onClose, children }: {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
}) {
if (!isOpen) return null;
return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-white rounded-lg p-6 max-w-md w-full">
{children}
</div>
</div>,
document.body
);
}
// Uso
<Modal isOpen={showModal} onClose={() => setShowModal(false)}>
<h2>Confirmar acción</h2>
<p>¿Estás seguro?</p>
</Modal>Cuándo usarlo: Cuando un componente necesita renderizarse visualmente fuera del árbol DOM de su padre (modales, dropdowns, tooltips, toasts) pero aún así participar en el árbol de eventos y contexto de React.
Ejemplo funcional
import { createPortal } from "react-dom";
import { useState, useEffect, useRef, useCallback, type ReactNode } from "react";
// --- Modal accesible con atrapamiento de foco ---
function Modal({
isOpen,
onClose,
title,
children,
}: {
isOpen: boolean;
onClose: () => void;
title: string;
children: ReactNode;
}) {
const dialogRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
// Atrapar foco y manejar escape
useEffect(() => {
if (!isOpen) return;
previousFocusRef.current = document.activeElement as HTMLElement;
dialogRef.current?.focus();
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
return;
}
if (e.key === "Tab" && dialogRef.current) {
const focusable = dialogRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last?.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first?.focus();
}
}
};
document.addEventListener("keydown", handleKeyDown);
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", handleKeyDown);
document.body.style.overflow = "";
previousFocusRef.current?.focus();
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Telón de fondo */}
<div
className="fixed inset-0 bg-black/50 animate-fade-in"
onClick={onClose}
aria-hidden="true"
/>
{/* Diálogo */}
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={title}
tabIndex={-1}
className="relative bg-white rounded-xl shadow-2xl p-6 max-w-lg w-full mx-4 animate-scale-in"
>
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold">{title}</h2>
<button
onClick={onClose}
aria-label="Close dialog"
className="p-1 rounded hover:bg-gray-100"
>
X
</button>
</div>
{children}
</div>
</div>,
document.body
);
}
// --- Tooltip usando portal ---
function Tooltip({
children,
content,
}: {
children: ReactNode;
content: string;
}) {
const [visible, setVisible] = useState(false);
const [coords, setCoords] = useState({ top: 0, left: 0 });
const triggerRef = useRef<HTMLSpanElement>(null);
const show = useCallback(() => {
if (triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
setCoords({
top: rect.top - 8 + window.scrollY,
left: rect.left + rect.width / 2 + window.scrollX,
});
}
setVisible(true);
}, []);
return (
<>
<span
ref={triggerRef}
onMouseEnter={show}
onMouseLeave={() => setVisible(false)}
onFocus={show}
onBlur={() => setVisible(false)}
>
{children}
</span>
{visible &&
createPortal(
<div
role="tooltip"
className="absolute -translate-x-1/2 -translate-y-full px-2 py-1 bg-gray-900 text-white text-sm rounded pointer-events-none"
style={{ top: coords.top, left: coords.left }}
>
{content}
</div>,
document.body
)}
</>
);
}
// --- Uso ---
function SettingsPage() {
const [showConfirm, setShowConfirm] = useState(false);
return (
<div className="p-8">
<h1>Configuración</h1>
<Tooltip content="Esto eliminará todos tus datos">
<button
onClick={() => setShowConfirm(true)}
className="text-red-600 underline"
>
Eliminar Cuenta
</button>
</Tooltip>
<Modal
isOpen={showConfirm}
onClose={() => setShowConfirm(false)}
title="Eliminar Cuenta"
>
<p className="mb-4">Esta acción no se puede deshacer. ¿Estás seguro?</p>
<div className="flex gap-2 justify-end">
<button onClick={() => setShowConfirm(false)} className="btn-secondary">
Cancelar
</button>
<button className="btn-danger">Eliminar</button>
</div>
</Modal>
</div>
);
}Lo que esto demuestra:
- Portal modal con atrapamiento de foco, manejo de tecla escape y bloqueo de desplazamiento
- Portal tooltip posicionado relativamente a su elemento disparador
- Atributos ARIA para accesibilidad (
role="dialog",aria-modal,aria-label) - Restauración del foco anterior cuando se cierra el modal
Profundización
Cómo funciona
createPortal(children, domNode)renderizachildrenendomNodeen lugar del nodo DOM padre.- A pesar de la colocación en el DOM, los portales permanecen parte del árbol de React. El contexto, los eventos y el estado fluyen como si el portal seguía siendo un hijo de su padre React.
- El burbujeo de eventos funciona a través del árbol de React, no del árbol DOM. Un clic dentro de un portal burbujea al padre React, no al padre DOM.
- Los portales se usan comúnmente para escapar de contextos de apilamiento CSS (
overflow: hidden, contenedoresz-index) que recortarían las superposiciones.
Parámetros y valores de retorno
| Parámetro | Tipo | Propósito |
|---|---|---|
children | ReactNode | Los elementos React a renderizar en el portal |
domNode | Element o DocumentFragment | El nodo DOM en el que renderizar |
key (opcional) | string | Clave única para el portal |
| Valor de retorno | ReactPortal | Un elemento React especial que representa el portal |
Variaciones
Contenedor portal dinámico — crear y limpiar un nodo DOM dedicado:
function usePortalContainer(id: string) {
const [container, setContainer] = useState<HTMLElement | null>(null);
useEffect(() => {
let element = document.getElementById(id);
if (!element) {
element = document.createElement("div");
element.id = id;
document.body.appendChild(element);
}
setContainer(element);
return () => {
if (element && element.childNodes.length === 0) {
element.remove();
}
};
}, [id]);
return container;
}
function ToastContainer({ children }: { children: ReactNode }) {
const container = usePortalContainer("toast-root");
if (!container) return null;
return createPortal(children, container);
}Portal compatible con servidor — protegerse contra document faltante:
function ClientPortal({ children }: { children: ReactNode }) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null;
return createPortal(children, document.body);
}Notas de TypeScript
createPortaldevuelveReactPortal, que es unReactNodeválido.- El segundo argumento debe ser un
ElementoDocumentFragment, nonull. Protégete con un condicional o verificación de estado. - Cuando uses portales en Next.js, siempre protégete con una verificación de montaje del lado del cliente ya que
documentno está disponible durante SSR.
Trampas
-
La herencia CSS se rompe — Los estilos heredados de elementos DOM padres (fuente, color) no se aplican al contenido portalizado ya que vive en otro lugar del DOM. Solución: Asegúrate de que el contenido portalizado defina sus propios estilos base o usa un envoltorio de reinicio de CSS.
-
Errores de SSR —
document.bodyno existe durante la renderización del lado del servidor. Solución: Protege el renderizado del portal con una verificación de montaje impulsada poruseEffecto usa"use client". -
Múltiples portales y guerras de z-index — Varios portales renderizándose a
document.bodypueden apilarse de forma impredecible. Solución: Usa una raíz de portal dedicada con un contexto de apilamiento, o gestiona z-index a través de un gestor de portales. -
Confusión de burbujeo de eventos — Los eventos burbujean a través del árbol de React, no del árbol DOM. Un clic dentro de un portal modal burbuja al padre React, lo que puede desencadenar manejadores no deseados. Solución: Usa
e.stopPropagation()en el contenido del portal si el padre tiene manejadores de clics en competencia. -
Gestión del foco — Abrir un portal sin mover el foco deja a los usuarios de teclado varados. Solución: Mueve el foco al portal al abrir y restáuralo al cerrar, como se muestra en el ejemplo funcional.
Alternativas
| Enfoque | Compensación |
|---|---|
createPortal | Control total; gestión manual de foco y accesibilidad |
Elemento HTML <dialog> | Modal nativa con atrapamiento de foco integrado; estilo limitado |
| Radix Dialog / Headless UI | Accesibilidad integrada completa; dependencia extra |
CSS position: fixed sin portal | Más simple; se rompe dentro de padres overflow: hidden o transform |
Popover API (atributo popover) | API de navegador nativa; compatibilidad limitada del navegador e integración con React |
FAQs
¿Qué hace createPortal y por qué es necesario?
createPortal(children, domNode)renderiza elementos hijos React en un nodo DOM fuera de la jerarquía DOM padre.- Es necesario para escapar de contextos de apilamiento CSS (
overflow: hidden, contenedoresz-index) que recortarían las superposiciones. - Los casos de uso comunes incluyen modales, tooltips, dropdowns y notificaciones toast.
¿Los portales permanecen siendo parte del árbol de componentes React aunque se rendericen en otro lugar del DOM?
- Sí. A pesar de la colocación en el DOM, los portales permanecen siendo parte del árbol de React.
- El contexto, los eventos y el estado fluyen como si el portal seguía siendo un hijo de su padre React.
- Esta es una diferencia clave con respecto a la adición manual de elementos DOM.
¿Cómo funciona el burbujeo de eventos con portales?
- Los eventos burbujean a través del árbol de React, no del árbol DOM.
- Un clic dentro de un portal modal burbuja al padre React, no al padre DOM (
document.body). - Esto puede desencadenar manejadores no deseados en el padre React. Usa
e.stopPropagation()si es necesario.
¿Cómo implementas atrapamiento de foco en un portal modal?
const focusable = dialogRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last?.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first?.focus();
}- Consulta todos los elementos enfocables dentro del diálogo.
- En Tab, envuelve el foco del último al primero (y viceversa con Shift+Tab).
Trampa: ¿Por qué el contenido portalizado pierde estilos CSS heredados?
- El contenido portalizado vive en una ubicación DOM diferente (p. ej.,
document.body), por lo que no hereda estilos padres como fuente, color o altura de línea. - Solución: asegúrate de que el contenido portalizado defina sus propios estilos base o use un envoltorio de reinicio de CSS.
Trampa: ¿Por qué createPortal se bloquea durante la renderización del lado del servidor?
document.bodyno existe durante SSR.- Solución: protege el renderizado del portal con una verificación de montaje impulsada por
useEffecto usa"use client"en Next.js.
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null;
return createPortal(children, document.body);¿Cuál es el tipo de retorno de createPortal en TypeScript?
createPortaldevuelveReactPortal, que es unReactNodeválido.- El segundo argumento debe ser un
ElementoDocumentFragment, nonull. - Protégete con un condicional o verificación de estado para evitar pasar
null.
¿Cómo escribes el parámetro domNode de createPortal en TypeScript?
- El tipo de parámetro es
Element | DocumentFragment. - Cuando uses una ref o estado para contener el contenedor, escríbelo como
HTMLElement | nully renderiza condicionalmente. - Ejemplo:
const [container, setContainer] = useState<HTMLElement | null>(null);
¿Cómo creas un contenedor portal dinámico que se limpie a sí mismo?
function usePortalContainer(id: string) {
const [container, setContainer] = useState<HTMLElement | null>(null);
useEffect(() => {
let el = document.getElementById(id);
if (!el) {
el = document.createElement("div");
el.id = id;
document.body.appendChild(el);
}
setContainer(el);
return () => { if (el && el.childNodes.length === 0) el.remove(); };
}, [id]);
return container;
}- Crea el contenedor al montar y elimínalo al desmontar si está vacío.
- Esto evita acumular nodos DOM obsoletos.
¿Por qué deberías restaurar el foco cuando un portal modal se cierra?
- Sin restauración del foco, los usuarios de teclado pierden su lugar en la página después de que se cierre el modal.
- Guarda el elemento enfocado anteriormente antes de abrir el modal y llama a
.focus()en él en la función de limpieza. - Este es un requisito de accesibilidad para diálogos.
¿Cómo interactúan múltiples portales con el apilamiento de z-index?
- Varios portales renderizándose a
document.bodypueden apilarse de forma impredecible porque comparten el mismo contexto de apilamiento. - Solución: usa una raíz de portal dedicada con un contexto de apilamiento gestionado, o asigna valores de z-index a través de un gestor de portales.
- Alternativamente, renderiza portales en orden de prioridad para que los portales posteriores naturalmente se apilen encima de los anteriores.
Relacionado
- Composition — Los portales usan composición de elementos hijos para inyección de contenido
- Error Boundaries — Envuelve portales en límites de error para seguridad
- Compound Components — Los diálogos modales a menudo usan APIs de componentes compuestos