O recurso React Portals permite renderizar um componente React em um nó DOM fora de seu pai direto, ou seja, é possível "teletransportar" JSX para qualquer lugar da árvore DOM. O caso de uso mais comum é quando um elemento visual precisa escapar do contêiner pai, como modais, tooltips flutuantes ou notificações.
<div>
<SomeComponent />
{createPortal(children, domNode, key?)}
</div>
Quando usamos ReactDOM.createPortal, o componente filho é renderizado em um nó DOM arbitrário, mantendo, no entanto, seu lugar na árvore de componentes React. Isso significa que eventos sintéticos e contexto continuam funcionando como se o componente estivesse na posição original.
Exemplo básico:
// Sem Portal
function App() {
return (
<>
<div>123</div>
<div className="modal">
<div>456</div>
</div>
</>
);
}
// Resultado DOM:
// <div id="root">
// <div>123</div>
// <div class="modal">
// <div>456</div>
// </div>
// </div>
// Com Portal
function App() {
return (
<>
<div>123</div>
{ReactDOM.createPortal(
<div className="modal">
<div>456</div>
</div>,
document.body
)}
</>
);
}
// Resultado DOM:
// <body>
// <div id="root">
// <div>123</div>
// </div>
// <div class="modal">
// <div>456</div>
// </div>
// </body>
Um padrão comum é encapsular o Portal em um componente:
function PortalComponent({ children }) {
if (typeof document === 'object') {
return ReactDOM.createPortal(children, document.body);
}
return null;
}
function App() {
return (
<PortalComponent>
<SomeChild />
</PortalComponent>
);
}
Eventos MouseEnter/MouseLeave no Contexto de Portais
Os eventos MouseEnter e MouseLeave não se propagam naturalmente (não há bubble), enquanto MouseOver e MouseOut sim. No React, MouseEnter e MouseLeave são tratados apenas na fase de captura, e não possuem variantes Capture explícitas.
Quando combinamos Portais com a árvore de componentes React, um fato curioso emerge: mesmo que o componente filho renderize em um nó DOM diferente, os eventos sintéticos ainda respeitam a hierarquia lógica do React. Por exemplo:
const ComponenteC = ReactDOM.createPortal(
<div onMouseEnter={() => console.log('C')} />,
document.body
);
const ComponenteB = ReactDOM.createPortal(
<div onMouseEnter={() => console.log('B')}>
{ComponenteC}
</div>,
document.body
);
function App() {
return (
<>
<div onMouseEnter={() => console.log('A')} />
{ComponenteB}
</>
);
}
A estrutura DOM será aproximadamente:
<body>
<div id="root">
<div id="A"></div>
</div>
<div id="B"></div> <!-- portal -->
<div id="C"></div> <!-- portal -->
</body>
Ao passar o mouse de A para B, o evento dispara em B (como esperado). Ao mover o mouse para C, entretanto, o console mostra B primeiro e depois C. Isso acontece porque na árvore React, C ainda é filho de B. O evento MouseEnter em C dispara primeiro no ancestral B (fase de captura) e depois em C.
Componente Trigger: Popups Aninhados com Portais
Esse comportamento de eventos é explorado para criar componentes de popover aninhados (como Trigger do ArcoDesign). A lógica é: cada popup é um Portal renderizado como filho do popup anterior na árvore React, mas no DOM eles ficam em paralelo. Assim, ao mover o mouse de um popup interno para o externo (ou vice‑versa), os eventos de entrada/saída são corretamente gerenciados pelo React, sem precisar de coordenação manual entre níveis.
Vejamos um exemplo simplificado de implementação:
// Componente Portal que cria um nó DOM dinâmico
function CustomPortal({ getContainer, children }) {
const containerRef = useRef(null);
if (!containerRef.current) {
containerRef.current = getContainer();
}
useEffect(() => {
return () => {
const container = containerRef.current;
if (container?.parentNode) {
container.parentNode.removeChild(container);
containerRef.current = null;
}
};
}, []);
return containerRef.current
? ReactDOM.createPortal(children, containerRef.current)
: null;
}
// Componente Trigger simplificado
class TriggerSimple extends React.Component {
state = { visible: false };
timer = null;
handleEnter = () => {
this.clearTimer();
this.setVisible(true, this.props.delay ?? 0);
};
handleLeave = () => {
this.clearTimer();
if (this.state.visible) {
this.setVisible(false, this.props.delay ?? 0);
}
};
handlePopupEnter = () => {
this.clearTimer();
};
handlePopupLeave = () => {
this.clearTimer();
if (this.state.visible) {
this.setVisible(false, this.props.delay ?? 0);
}
};
setVisible = (visible, delay = 0) => {
if (visible === this.state.visible) return;
this.schedule(delay, () => {
this.setState({ visible });
});
};
schedule = (delay, cb) => {
if (delay) {
this.clearTimer();
this.timer = setTimeout(() => {
cb();
this.clearTimer();
}, delay);
} else {
cb();
}
};
clearTimer = () => {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
};
render() {
const { children, popup } = this.props;
const wrappedChildren = React.Children.map(children, child =>
React.isValidElement(child)
? React.cloneElement(child, {
onMouseEnter: this.handleEnter,
onMouseLeave: this.handleLeave,
})
: child
);
return (
<>
{wrappedChildren}
{this.state.visible && (
<CustomPortal getContainer={() => {
const div = document.createElement('div');
div.style.position = 'absolute';
div.style.top = '0';
div.style.left = '0';
document.body.appendChild(div);
return div;
}}>
<div
onMouseEnter={this.handlePopupEnter}
onMouseLeave={this.handlePopupLeave}
>
{popup()}
</div>
</CustomPortal>
)}
</>
);
}
}
Uso:
<TriggerSimple
delay={200}
popup={() => (
<div id="popupB" style={{ width: 100, height: 100, background: 'green' }}>
<TriggerSimple
delay={200}
popup={() => (
<div id="popupD" style={{ width: 50, height: 50, background: 'blue' }} />
)}
>
<div id="childC">Passe o mouse</div>
</TriggerSimple>
</div>
)}
>
<div id="childA" style={{ width: 150, height: 150, background: 'red' }} />
</TriggerSimple>
Ao passar o mouse de A para B, o popup B aparece. Movendo para C (filho de B), o popup D aparece, e C e D permanecem abertos enquanto o mouse estiver sobre eles. Saindo de D direto para o vazio, todos fecham. Saindo de D para B, apenas D fecha. Tudo isso funciona porque:
- O popup B é renderizado como filho de A na árvore React (via Portal), então o evento de saída de A não é disparado quando o mouce vai para B.
- O popup D, por sua vez, é filho de B na árvore React, então eventos de mouse em D impedem que B feche.
- Os timers de atraso garantem que movimetnos rápidos entre elementos não causem fechamento indesejado.
Esse padrão permite popups aninhados arbitrariamente sem necessidade de estado global ou comunicação explícita entre níveis.