DOMの「境界」をハックする:React Portalsが解決するアーキテクチャの真実
フロントエンド開発において、CSSの `z-index` 泥沼や `overflow: hidden` の制約に絶望した経験はないだろうか。モーダルやツールチップを実装する際、親コンポーネントのDOM構造に依存したレンダリングを行っていると、必ずどこかで「親のスタイルが子に干渉する」という地獄を見る。
React Portalsは、単なる「DOMを別の場所に飛ばす魔法」ではない。これは、Reactの宣言的なコンポーネントツリーと、ブラウザのDOM構造という二つの異なるレイヤーを、適切に同期させるためのアーキテクチャ上の戦略だ。
今日は、ただ「動くコード」を書くのではなく、ブラウザエンジンとReactのファイバー(Fiber)アーキテクチャを理解した上での、堅牢なPortal設計について語ろう。
—
1. なぜ「物理的なDOMの分離」が必要なのか
Reactのレンダリングは本質的にコンポーネント階層に従うが、ブラウザの描画はDOMのスタッキングコンテキストに従う。モーダルが親の `overflow: hidden` に切り取られる現象は、Reactの仮想DOM階層と、ブラウザがレンダリングする物理的なDOM階層が一致しているがゆえに起こる「仕様上の必然」だ。
Portalsを使うことで、論理的にはコンポーネントの配下にありつつ、物理的には `document.body` 直下のような、別のDOMツリーへ出力できる。これにより、CSSのスタッキングコンテキストから解放され、かつイベントバブリングの恩恵も受けられるという、まさに「いいとこ取り」が可能になる。
—
2. 高度な実装:ポータル管理のアーキテクチャ
単に `createPortal` を使うだけなら公式ドキュメントで十分だ。しかし、実務レベルでは「ポータル用のコンテナがDOM上に存在しない」という初期化リスクや、メモリリークを考慮する必要がある。
以下は、React 18の `useLayoutEffect` を活用し、安全にDOMを注入する設計パターンだ。
import { useEffect, useState, useLayoutEffect } from ‘react’;
import { createPortal } from ‘react-dom’;
/
- 安全にポータルコンテナを管理するフック
- 頻繁な再レンダリングによるDOMの不必要な生成を防ぐため、
- コンテナを一度だけ生成し、再利用する設計にしている。
/
const usePortal = (containerId: string) => {
const [container, setContainer] = useState
useLayoutEffect(() => {
let element = document.getElementById(containerId);
let created = false;
// コンテナが存在しない場合のみ生成する
if (!element) {
created = true;
element = document.createElement(‘div’);
element.setAttribute(‘id’, containerId);
document.body.appendChild(element);
}
setContainer(element);
// クリーンアップ:コンポーネントがアンマウントされたらDOMも破棄する
return () => {
if (created && element?.parentNode) {
element.parentNode.removeChild(element);
}
};
}, [containerId]);
return container;
};
// 使用例:モーダルコンポーネント
export const Modal = ({ children, isOpen }: { children: React.ReactNode, isOpen: boolean }) => {
const container = usePortal(‘modal-root’);
if (!isOpen || !container) return null;
return createPortal(
,
container
);
};
—
3. パフォーマンスと非同期の競合に対する知見
Portalsを使用する際、初心者が陥りがちなのが「無駄な再レンダリング」と「イベントの競合」だ。
- レンダリング負荷の最適化:
Portal内のコンテンツは、親コンポーネントが再レンダリングされるたびに更新される可能性がある。もしポータルの中身が非常に重いコンポーネント(複雑なグラフやリスト)である場合、`React.memo` を適切に使用し、不要な計算を遮断せよ。
- イベントバブリングの「罠」:
Portalは物理的にDOMが離れていても、Reactのイベントシステム上は「親コンポーネントのツリー」に存在している。つまり、Portal内で発生したクリックイベントは、通常のReactコンポーネントと同じように親へバブルアップする。これを理解せず、DOMレベルの `e.stopPropagation()` を使うと、React側のイベントハンドラが発火せず、予期せぬバグを招く。Reactの合成イベントシステムとネイティブDOMイベントは別物であることを常に意識してほしい。
- メモリ効率:
SPAにおいて、ポータルコンテナを `document.body` に作りすぎると、DOMツリーが肥大化する。コンテナはアプリケーション単位で共有するか、あるいはコンポーネントのライフサイクルに合わせて動的に破棄する設計が必須だ。
—
4. まとめ:なぜ私たちは「境界」を越えるのか
Portalsは、Reactが提供する「カプセル化」という強力な武器を、ブラウザの物理的制約から切り離すための重要なレバーだ。
我々エンジニアが目指すべきは、単に画面を表示することではない。「コンポーネントがどこに配置されても、その機能が完全に独立し、かつ親の環境と調和する」という、極めて高い疎結合性を維持することだ。
もしあなたが今、CSSの修正だけでモーダルの挙動に苦労しているなら、それはアーキテクチャの敗北だ。Portalsを適切に活用し、コンポーネントを「適切な場所」へと解き放ってほしい。それが、長年メンテナンスされ続ける堅牢なフロントエンドを構築するための、唯一の道である。

コメント