React Portals. Construyendo agujeros de gusano
Cómo emplear los React Portals para saltarte algunas de las restricciones que impone el DOM
Hace poco estuve trabajando en una aplicación que empleaba los React Portals y me pareció una característica muy interesante. Los portales nos permiten de forma nativa renderear componentes en un nodo del DOM que exista fuera de la jerarquía del componente padre.
Los React Portals son una API que nos permite renderear componentes “saltándonos” la jerarquía del DOM
Según la documentación oficial los portales resultan útiles cuando un componente padre tiene una propiedad del estilo z-index
o overflow: hidden
pero su hijo tiene que “romper” el contenedor, como por ejemplo en el caso de los tooltips, ventanas modales o menús flotantes.
Como de primeras todo esto puede sonar muy abstracto, voy a mostraros dos casos de uso para que podáis ver su funcionamiento.
¡Vamos a ello!
Creando una ventana modal con React Portals
El primer caso es bastante sencillo y es, seguramente, el más típico para explicar los React Portals. Lo que haremos será desarrollar una aplicación que nos permita mostrar una ventana modal pulsando un botón.
Partiremos de un proyecto creado desde cero. Dentro de él, abriremos el archivo index.html
y añadiremos el elemento que contendrá las ventanas modales de nuestra aplicación. Sobre este elemento crearemos posteriormente el portal.
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="react"></div>
<div id="modalContainer"></div>
</body>
Como veis, el elemento modalContainer
se encuentra al mismo nivel que el elemento react
(que es donde se montará nuestra aplicación). La magia de los React Portals nos permitirá renderear componentes en el elemento del DOM modalContainer
saltándonos la jerarquía de componentes de React que creemos en nuestra aplicación.
Para ello, el siguiente paso será crear nuestro componente Modal
, que tendrá el siguiente aspecto
import ReactDOM from "react-dom";
import { CSSTransition } from "react-transition-group";
import "./Modal.css";
const modalContainer = document.querySelector("#modalContainer");
export default function Modal({ title, children, isOpened, onClose }) {
if (!isOpened) {
return null;
}
return ReactDOM.createPortal(
<div className="modal" tabIndex="-1" role="dialog">
<CSSTransition
appear
in
classNames="modal-transition"
unmountOnExit
timeout={300}
>
<div className="modal-dialog" role="document">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">{title}</h5>
<button onClick={onClose}>
<span aria-hidden="true">×</span>
</button>
</div>
<div className="modal-body">{children}</div>
</div>
</div>
</CSSTransition>
</div>,
modalContainer
);
}
Este componente recibe 4 propiedades pero lo que realmente nos interesa es que es aquí donde aparece el uso de ReactDOM.createPortal
. Esta función recibe dos argumentos:
El JSX que queremos “renderizar” como haríamos en un componente “normal”.
El elemento del DOM donde queremos “renderizarlo”. Como el elemento se encuentra fuera de la jerarquía que define nuestra aplicación, necesitamos recurrir a
document.querySelector
para seleccionar nuestro<div id="modalContainer"></div>
que añadimos en el archivoindex.html
.
Hecho esto, tan sólo quedará añadir nuestro componente dentro del archivo App.js
y la “magia” de los React Portals hará el resto:
import { useState } from "react";
import "./App.css";
import Modal from "./components/Modal";
function App() {
const [isOpened, setOpened] = useState(false);
const openModal = () => setOpened(true);
const closeModal = () => setOpened(false);
return (
<div className="App">
<header className="App-header">
<button className="btn btn-primary" onClick={openModal}>
Abrir modal
</button>
<div>Counter: {counter}</div>
<Modal title="Welcome" isOpened={isOpened} onClose={closeModal}>
This is a modal
</Modal>
</header>
</div>
);
}
export default App;
Lo realmente interesante es que, desde el punto de vista de React, la estructura de componentes es la que vemos representada en nuestros archivos. Sin embargo, al hacer click sobre el botón Open Modal
la estructura del DOM que obtenemos es la siguiente:
Repositorio
En el caso de que queráis ver en más detalle el código podéis ver el siguiente repositorio:
https://github.com/ger86/react-portals-modal
Propagación de eventos
Otra de las cosas curiosas al trabajar con los React Portals es la forma en que se propagan los eventos. Aunque el contenido del portal se pinte en otra parte del DOM, el componente a nivel interno se sigue comportando como si de un hijo del componente padre se tratase.
Un evento activado desde dentro de un portal se propagará a los ancestros en el árbol de React, incluso si esos elementos no son ancestros en el árbol DOM.
Veámoslo modificando un poco el ejemplo anterior:
import { useState } from "react";
import "./App.css";
import Modal from "./components/Modal";
function Child() {
return <button>Click</button>;
}
function App() {
const [isOpened, setOpened] = useState(false);
const [counter, setCounter] = useState(0);
const openModal = () => setOpened(true);
const closeModal = () => setOpened(false);
const handleClick = () => setCounter(counter + 1);
return (
<div className="App" onClick={handleClick}>
<header className="App-header">
<button className="btn btn-primary" onClick={openModal}>
Abrir modal
</button>
<div>Counter: {counter}</div>
<Modal title="Welcome" isOpened={isOpened} onClose={closeModal}>
<Child />
</Modal>
</header>
</div>
);
}
export default App;
El componente <Child>
, que pintamos dentro de nuestro componente Modal
posee un botón capaz de recibir clicks. Además, hemos añadido un onClick
de modo que cada click dentro del componente App
incremente el contador.
Lo interesante es que, aunque hagamos click en el botón pintado dentro de la modal (recuerda que en el DOM del navegador está dentro del elemento modalContainer
), el componente App
sigue recibiendo los clicks. Esto es gracias a que React mantiene la jerarquía de los componentes.
Agujeros de gusano y React Portals
Veamos ahora un ejemplo algo más interesante: crear un agujero de gusano con React. Según la teoría:
En física, un agujero de gusano, también conocido como puente de Einstein-Rosen, es una hipotética característica topológica de un espacio-tiempo, descrita en las ecuaciones de la relatividad general, que esencialmente consiste en un atajo a través del espacio y el tiempo.
Para simular el comportamiento de un agujero de gusano empleando los portales de React crearemos un nuevo proyecto e instalaremos la librería react-draggable
que nos permitirá mover nuestra nave espacial por el espacio:
yarn add react-draggable
La idea del proyecto será tener dos “portales” que nos permitan conectar una nave espacial entre el espacio A (donde se encuentra el root
de nuestra aplicación de React) y el espacio B.
Para comenzar, crearemos el componente que representará nuestra nave espacial:
export default function Starship({ className, ...rest }) {
return (
<div {...rest} className={`${className} starship`}>
<span role="img" aria-label="starship">
🚀
</span>
</div>
);
}
Después, añadiremos a nuestro archivo index.html
el elemento que contendrá el espacio B de nuestra aplicación:
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="react"></div>
<div id="spaceB">
</div>
Ahora es donde comienza la parte interesante. Queremos poder desplazar nuestra nave por el espacio A hasta el agujero de gusano para que, al entrar, se traslade automáticamente al espacio B.
Para ello, dentro de nuestro componente principal escribiremos lo siguiente:
import { useState, useRef } from "react";
import ReactDOM from "react-dom";
import Draggable from "react-draggable";
import overlaps from "utils/overlaps";
import Starship from "./components/Starship";
import "./App.css";
const spaceB = document.querySelector("#spaceB");
function App() {
const [isInSpaceA, setIsInSpaceA] = useState(true);
const wormholeARef = useRef();
const wormholeBRef = useRef();
function checkInsideWorkmholeA(event) {
if (
overlaps(
event.target.getBoundingClientRect(),
wormholeARef.current.getBoundingClientRect()
)
) {
setIsInSpaceA(false);
}
}
function checkInsideWorkmholeB(event) {
if (
overlaps(
event.target.getBoundingClientRect(),
wormholeBRef.current.getBoundingClientRect()
)
) {
setIsInSpaceA(true);
}
}
return (
<div className="App">
{isInSpaceA && (
<Draggable
axis="both"
bounds="parent"
onStop={checkInsideWorkmholeA}
>
<Starship />
</Draggable>
)}
<div className="wormhole-a" ref={wormholeARef} />
{!isInSpaceA &&
ReactDOM.createPortal(
<Draggable
axis="both"
bounds="parent"
onStop={checkInsideWorkmholeB}
>
<Starship />
</Draggable>,
spaceB
)}
{ReactDOM.createPortal(
<div className="wormhole-b" ref={wormholeBRef}></div>,
spaceB
)}
</div>
);
}
export default App;
Mucha tela que cortar aquí, ¿no? Vayamos por partes.
Primero obtenemos el elemento
#spaceB
donde se encuentra el espacio B.
const spaceB = document.querySelector("#spaceB");
Definimos un estado interno mediante el hook
useState
que almacenará en qué espacio se encuentra nuestra nave.
const [isInSpaceA, setIsInSpaceA] = useState(true);
Guardamos dos referencias a los “divs” por los que se desplazará la nave mediante el hook
useRef
. Así podremos comprobar si la nave ha llegado a ellos cuando se lance el eventoonStop
del componenteDraggable
.
const wormholeARef = useRef();
const wormholeBRef = useRef();
La función
checkInsideWormholeA
se encarga de comprobar si la nave ha llegado al agujero de gusano obteniendo elboundingRect
de la nave y del agujero de gusano. Usaremos la funciónoverlaps
para realizar esa comprobación.
const overlaps = (rect1, rect2) =>
!(
rect1.right < rect2.left ||
rect1.left > rect2.right ||
rect1.bottom < rect2.top ||
rect1.top > rect2.bottom
);
export default overlaps;
Dentro del JSX, añadimos dos portales sobre el espacio B. Uno para añadir el Draggable cuando la nave aparezca allí, y otro con el agujero de gusano que le permitirá volver al espacio A.
{!isInSpaceA &&
ReactDOM.createPortal(
<Draggable axis="both" bounds="parent" onStop={checkInsideWorkmholeB}>
<Starship />
</Draggable>,
spaceB
)}
--- Agujero de gusano B ---
{ReactDOM.createPortal(
<div className="wormhole-b" ref={wormholeBRef}></div>,
spaceB
)}
Finalmente, la función
checkInsideWormholeB
comprueba si la nave se encuentra en el agujero de gusano del espacio B para poder volver al A.
Si quieres, puedes ver el código en el siguiente repositorio:
https://github.com/ger86/react-portals-wormhole
🙏🏻 Gracias a Garaje de Ideas
Los amigos de Garaje de ideas patrocinan Latte and Code y buscan talento: http://bit.ly/garaje-tech-talento
Conclusiones y referencias
Como habéis podido comprobar, la API ReactDOM.createPortal
abre un mundo de posibilidades a la hora de poder manipular la estructura del DOM. Estos dos ejemplos son solo la punta del iceberg de algunas de las cosas que se pueden llegar a lograr gracias a esta característica; por ejemplo, empleando los React Portals podemos incluso mover elementos entre ventanas:
https://medium.com/hackernoon/using-a-react-16-portal-to-do-something-cool-2a2d627b0202
Además, podéis profundizar más en la documentación oficial y ver más ejemplos como el de este artículo.
Espero que este artículo os haya gustado y os anime a profundizar más en esta funcionalidad de React tan desconocida.