【テクニカル・上級編】 React PortalsによるDOM階層の分離 – React実践ガイド

React Portalsの深淵:DOM階層の「制約」を突破し、真に堅牢なUIを構築する

Reactを書き始めてしばらくすると、誰もが一度は「CSSの`overflow: hidden`や`z-index`の地獄」に突き当たる。モーダルを開いた瞬間、親要素のコンテナに閉じ込められ、スクロールバーの影に隠れて消えるツールチップ。これを解決するためにDOMを無理やりJavaScriptで弄り回すのは、Reactの宣言的パラダイムに対する冒涜だ。

そこで登場するのが `createPortal` だ。だが、これを単なる「モーダル用ツールチップ出し機」だと思っているなら、それは大きな勘違いだ。今日は、上級エンジニアの視点から、Portalsの内部挙動と、現場で踏み抜くべきではない地雷について深く潜っていく。

—

1. Portalsの本質は「レンダリングの切断」ではなく「ツリーの拡張」

`createPortal(children, container)` は、ReactのFiberツリーの構造を物理的なDOM階層から切り離す。重要なので強調するが、Reactのツリー上の親子関係(コンテキストやイベントバブリング)は維持されるが、DOM上の親子関係は解消される。

これが何を意味するか? 物理的にDOMのツリー構造が離れていても、`useContext` の値は伝播し、`onClick` などのイベントは親へと伝わる。Reactのレンダリングエンジンは、Fiberノードを追いかける際に、あえて「異次元のDOM」に値を書き込んでいるに過ぎない。この「論理的な連続性」と「物理的な独立性」の共存こそが、Portalsの真骨頂だ。

—

2. 現場で直面する「非同期の競合」と「DOMの生存確認」

Portalsを使う際、最も悲惨なバグは「`container`となるDOM要素が、Reactがレンダリングを試みる瞬間にまだ存在しない」という事態だ。

特に、`useEffect` 内でDOMを生成したり、サードパーティライブラリを組み込む場合、レンダリング順序の不整合でランタイムエラーを吐くことがある。これを防ぐには、「Portalコンテナ専用のカスタムフック」を作成し、DOMのライフサイクルをReactの管理下に置くのが鉄則だ。

実践的な実装パターン

import { useEffect, useState } from ‘react’;
import { createPortal } from ‘react-dom’;

/

  • Portal用のコンテナを安全に生成するフック
  • 頻繁なDOM操作によるパフォーマンス劣化を防ぐため、一度生成した要素はキャッシュする

/
const usePortalContainer = (id: string) => {
const [container, setContainer] = useState(null);

useEffect(() => {
let element = document.getElementById(id);
let created = false;

if (!element) {
created = true;
element = document.createElement(‘div’);
element.setAttribute(‘id’, id);
document.body.appendChild(element);
}

setContainer(element);

// クリーンアップ処理: コンポーネント破棄時にDOMも綺麗に掃除する
// これを忘れると、モーダルを開閉するたびにbodyにゴミが溜まり続ける
return () => {
if (created && element?.parentNode) {
element.parentNode.removeChild(element);
}
};
}, [id]);

return container;
};

export const Modal = ({ children }: { children: React.ReactNode }) => {
const container = usePortalContainer(‘modal-root’);

// containerがnullの間は何もレンダリングしない(SSR時のハイドレーション不一致回避)
if (!container) return null;

return createPortal(

{children}

,
container
);
};

—

3. パフォーマンス最適化:レンダリング負荷を最小化する

Portalsを使用すると、親コンポーネントが再レンダリングされるたびに、Portalの中身も再評価される可能性がある。モーダルの中に重いグラフ描画や大量のリストが含まれている場合、これはアプリケーション全体のパフォーマンスを著しく低下させる。

  • `React.memo` の徹底: Portalの子要素には必ず `memo` を適用し、propsの変更がない限り再レンダリングを阻止せよ。
  • イベントハンドラの分離: Portal内で行われる状態更新は、親のFiberツリー全体を巻き込む可能性がある。必要であれば、Portal内の状態管理を別個のコンテキストに切り出し、レンダリングパスを分離する設計が必要だ。

—

4. なぜ「イベントバブリング」には注意が必要なのか?

初心者が陥る罠の一つが、Portal内でのイベント伝播だ。Reactのイベントシステムは、Portal越しでも親に向かってバブリングする。しかし、ブラウザネイティブのイベント(`addEventListener`等で直接登録したもの)は、PortalのDOM階層に依存する。

Reactのイベント(`onClick`など)と、ネイティブのDOMイベントが混在する環境では、Portalは「論理的には親の下にあるが、ブラウザ上では別の場所にある」という矛盾を抱える。複雑なツールチップやドラッグ&ドロップ機能を実装する際は、可能な限りReactの合成イベント(SyntheticEvent)に統一し、ネイティブイベントへの直接介入を避けるのが、重大なバグを回避する賢明な選択だ。

—

結論:アーキテクトとしての矜持

`createPortal` は、Reactの制約を拡張するための強力な武器だが、同時にReactの「単一のツリーで全てを管理する」という強力な規律を部分的に無効化する諸刃の剣でもある。

我々スペシャリストが目指すべきは、DOM構造を自由に操ることではない。「どの要素がどのライフサイクルに属し、どこでメモリが解放されるべきか」というデータフローの整合性を、物理的な配置に惑わされずに管理し続けることだ。

コードを記述する際、常に「このPortalは本当に分離が必要か? CSSの `position: fixed` や `absolute` で解決できないか?」と自問自答してほしい。その葛藤の末に選んだ `createPortal` こそが、保守可能で堅牢なWebアプリケーションの礎となるのだ。

コメント

タイトルとURLをコピーしました