Portais React (React Portals): Renderização Fora da Árvore DOM

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.

Tags: React React Portals createPortal Event Handling MouseEnter

Publicado em 7-2 06:27