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
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(
,
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アプリケーションの礎となるのだ。

コメント