wandres.dev
DYNAMIC, PORTAL, ERRORBOUNDARY · componentes especiales

Portal: renderizar fuera del árbol del padre

Portal saca contenido del subarbol DOM del componente y lo monta en otro punto del documento, el body por defecto, para modales, tooltips y toasts que escapan de overflow, z-index y transform, sin perder el contexto ni el arbol de eventos de Solid.

⏱ 15 min

Hay UI que no cabe donde nace. Un modal atrapado dentro de un contenedor con overflow: hidden se recorta; un tooltip bajo un ancestro con transform hereda un nuevo contexto de apilamiento y su z-index deja de valer; un toast debería vivir sobre todo lo demás, no anidado en la tarjeta que lo disparó. Portal resuelve esto rompiendo la correspondencia entre el árbol de componentes y el árbol del DOM: los hijos se crean donde los escribes, pero se insertan en otro nodo del documento.

🎯 Al terminar esta lección sabrás
  • Mover contenido fuera del subárbol del padre con Portal y elegir el destino con mount.
  • Conocer qué envuelve Portal y las opciones useShadow e isSVG.
  • Comprobar que el contexto de Solid sigue disponible dentro del Portal.
  • Entender que los eventos delegados suben por el árbol de componentes, no por el del DOM.

A dónde monta

Portar tiene sentido para un puñado de patrones recurrentes, todos con el mismo problema de fondo: un elemento que debe pintarse por encima o fuera del recorte de sus ancestros.

🪟

Modales y diálogos

Ocupan la ventana entera y capturan el foco; no pueden quedar atrapados por el overflow de una tarjeta.

💬

Tooltips y popovers

Se posicionan junto a un ancla pero deben desbordar su contenedor sin recortarse.

🔔

Toasts y avisos

Viven en una capa global sobre todo el layout, ajenos a dónde se dispararon.

📋

Menús y desplegables

Se abren desde un botón, pero su lista debe flotar por encima del resto de la interfaz.

Portal vive en solid-js/web. Sin más props, crea un <div> contenedor, lo añade al final de document.body e inserta ahí sus hijos. Con la prop mount eliges otro nodo destino —un contenedor dedicado a toasts, por ejemplo—:

import { Portal } from "solid-js/web";

function Toast(props: { children: JSX.Element }) {
  return (
    <Portal mount={document.getElementById("capa-toasts")!}>
      <div role="status" class="toast">{props.children}</div>
    </Portal>
  );
}

Dos opciones afinan el comportamiento. useShadow monta los hijos dentro de un shadow root del contenedor, aislando estilos del resto de la página. isSVG hace que el contenedor sea un <g> en vez de un <div>, necesario cuando portas nodos dentro de un <svg>. Un ref sobre el Portal te da el nodo contenedor.

Prop Efecto
mount nodo destino; por defecto document.body
useShadow encierra los hijos en un shadow root aislado
isSVG usa un contenedor <g> para contextos SVG
⚠️
El destino debe existir al montar

mount se resuelve cuando el Portal se crea. Si apuntas a un nodo que aún no está en el documento —un contenedor que renderiza otro componente más abajo— obtienes null y falla. En SSR no hay document: el contenido del Portal se emite y se hidrata en el cliente, así que todo acceso directo como mount debe protegerse o diferirse a onMount. Para el body por defecto no hay problema; para destinos propios, garantiza su existencia antes.

El contexto viaja con el Portal

Aquí está la propiedad que hace al Portal de Solid superior a un portal ingenuo del DOM: aunque el nodo aterrice en document.body, los hijos se crean dentro del owner donde escribiste el Portal. Por tanto conservan todo lo que depende del árbol reactivo: useContext, el tema, el idioma, la inyección de dependencias. No hay que reconectar nada.

const Tema = createContext<"claro" | "oscuro">("claro");

function App() {
  return (
    <Tema.Provider value="oscuro">
      <Portal>
        <Dialogo />
      </Portal>
    </Tema.Provider>
  );
}
// dentro de Dialogo, useContext(Tema) devuelve "oscuro" pese a vivir en el body

Ese Dialogo, pese a colgar del body, lee el mismo context que sus hermanos del árbol lógico. La separación es puramente espacial en el DOM; en el grafo de Solid siguen siendo padre e hijo.

Los eventos suben por el árbol de Solid

El corolario más contraintuitivo: los eventos delegados de Solid —onClick, onInput y demás— se propagan siguiendo la jerarquía de componentes, no la del DOM. Un clic dentro del contenido portado burbujea hacia los manejadores de los componentes ancestros en el árbol lógico, aunque físicamente el nodo cuelgue del body.

flowchart TD
ROOT[App root] --> PANEL[Panel con overflow hidden]
PANEL --> MARCA[Portal marcador logico]
BODY[document body] --> CAJA[div contenedor del Portal]
CAJA --> MODAL[Modal visible fuera del panel]
MODAL -.el onClick sube por el arbol de Solid.-> PANEL
style CAJA fill:#89b4fa,color:#11111b
style MODAL fill:#a6e3a1,color:#11111b

En la práctica, un onClick puesto en un componente que envuelve al Portal recibe los clics de dentro del portal. Casi siempre es lo que quieres —cerrar un menú al pulsar en cualquier sitio—, pero puede provocar cierres o navegaciones fantasma si no lo prevés. Los eventos nativos no delegados —los que registras con addEventListener— sí siguen el DOM real, y ahí el contenido portado está en body, ajeno a tus paneles. Distinguir ambos planos es la clave para no perseguir bugs imposibles.

ℹ️
Delegados frente a nativos

Solid delega un conjunto de eventos comunes en la raíz del documento y los reemite por el árbol de componentes; por eso cruzan el Portal. Un addEventListener manual sobre un nodo, en cambio, obedece la propagación real del DOM. Si un manejador se comporta distinto según lo pongas como onClick o como listener manual, no estás loco: estás viendo los dos árboles a la vez.

Posicionar lo portado

Un modal se centra con CSS, pero un tooltip o un popover deben aparecer junto a su ancla, y el ancla vive en tu árbol mientras el contenido vive en body. El puente es leer la geometría del ancla con getBoundingClientRect y aplicarla como posición fija al contenido portado:

import { Portal } from "solid-js/web";
import { createSignal, Show } from "solid-js";

function ConTooltip(props: { children: JSX.Element; texto: string }) {
  const [rect, setRect] = createSignal<DOMRect>();
  let ancla!: HTMLSpanElement;
  return (
    <>
      <span
        ref={ancla}
        onMouseEnter={() => setRect(ancla.getBoundingClientRect())}
        onMouseLeave={() => setRect(undefined)}
      >
        {props.children}
      </span>
      <Show when={rect()}>
        {(r) => (
          <Portal>
            <div role="tooltip" style={{ position: "fixed", top: `${r().bottom}px`, left: `${r().left}px` }}>
              {props.texto}
            </div>
          </Portal>
        )}
      </Show>
    </>
  );
}

Como el tooltip se monta en body, position: fixed lo sitúa respecto a la ventana con las coordenadas del ancla, sin que ningún overflow intermedio lo recorte. Para casos serios —colisiones con los bordes, scroll de la página, volteo automático— se delega en una librería de posicionamiento como Floating UI, pero el mecanismo de fondo es exactamente este: leer el rect del ancla y proyectarlo sobre el nodo portado.

Portal separa dónde se crea algo de dónde se ve

La idea que corona este nivel es que en Solid la posición en el DOM y la posición en el grafo reactivo son dos coordenadas independientes, y Portal es la herramienta que las desacopla a voluntad. Un portal del DOM crudo te reubica el nodo y te abandona: pierdes el contexto, reconectas listeners a mano, gestionas la limpieza tú. El Portal de Solid reubica solo la coordenada física —el nodo se inserta en body para escapar de overflow, de z-index y de transform— mientras preserva intacta la coordenada lógica: el hijo sigue siendo hijo en el árbol de owners, hereda su context, participa de su ciclo de vida y devuelve los eventos delegados a sus ancestros reactivos. Esta dualidad explica de un plumazo los tres fenómenos que confunden a quien llega: por qué el useContext funciona donde “no debería”, por qué un onClick externo caza clics del modal, y por qué al cerrar el Portal la limpieza ocurre sola. No son excepciones ni magia: son la consecuencia directa de que crear e insertar son actos distintos, y de que el árbol que importa para la reactividad nunca fue el del DOM.

⚔️ Escapa del contenedor sin perder el árbol
  1. Mete un modal dentro de un contenedor con overflow: hidden y transform; observa cómo se recorta. Envuélvelo en Portal y comprueba que ahora se muestra completo sobre la página.
  2. Crea un <div id="capa-toasts"> en el body y monta ahí tus toasts con mount; verifica que aparecen agrupados fuera del flujo.
  3. Pon un Provider de context arriba y consúmelo desde un componente dentro del Portal; confirma que recibe el valor.
  4. Coloca un onClick en el panel que envuelve al Portal y otro dentro del contenido portado; observa cómo el clic interno también dispara el externo.
  5. Repite el punto anterior registrando el manejador externo con addEventListener manual y comprueba que ahí el clic del portal ya no lo alcanza.