Reactのポータルは、DOM階層の制約という根本的な問題を解決します。深くネストされたコンポーネント内でモーダルをレンダリングすると、親のz-indexスタッキングコンテキストやoverflow: hiddenによるクリッピングの影響を受けます。ポータルはReactツリー上のコンテキストを保持したまま子要素を別のDOMノードにレンダリングすることで、この問題を解決します。
重要なのは、DOM上の位置とReactツリー上の位置を分離して考えることです。ポータルの子要素は物理的には別の場所にレンダリングされますが、イベントはDOM階層ではなくReactコンポーネント階層を通じてバブリングします。このメンタルモデルを理解することが、すべてのポータルパターンの基礎となります。
createPortal API
APIは最小限です:ReactDOM.createPortal(children, domNode, key?)。domNodeはレンダリング前に存在している必要があるため、通常はHTMLテンプレートにあらかじめコンテナを用意するか、eagerに作成します。
import { createPortal } from "react-dom";
function Modal({ children, isOpen }) {
if (!isOpen) return null;
return createPortal(
<div className="modal-overlay">
<div className="modal-content" role="dialog" aria-modal="true">
{children}
</div>
</div>,
document.body
);
}
オプションの第3引数keyはポータルの再調整(reconciliation)を制御します。キーを変更するとポータルサブツリーが強制的に再マウントされ、内部状態をリセットしたい場合に有用です。
function Portal({ children, selector, key }) {
const [container, setContainer] = useState(null);
useEffect(() => {
const el = document.querySelector(selector) || document.body;
setContainer(el);
}, [selector]);
if (!container) return null;
return createPortal(children, container, key);
}
ポータルターゲット管理
document.bodyのハードコーディングは単純なケースでは機能しますが、複雑なレイアウトでは限界があります。Reactコンテキストを通じて名前付きターゲットを管理する方法が推奨されます。
const PortalContext = createContext(null);
function PortalHost({ name, children }) {
const [container, setContainer] = useState(null);
useEffect(() => {
const el = document.createElement("div");
el.id = `portal-${name}`;
document.body.appendChild(el);
setContainer(el);
return () => document.body.removeChild(el);
}, [name]);
if (!container) return null;
return createPortal(children, container);
}
これによりマルチゾーンレンダリングが可能になります。モーダルは#modal-root、ツールチップは#tooltip-root、通知は#notification-rootへ。各ゾーンは独自のスタイルとレイアウトを持ち、コンポーネントは宣言的にターゲットを選択します。SSRはuseEffectガードにより自動的に処理されます。
ネストポータルとイベント伝搬
ポータル内のコンポーネントがさらにポータルをレンダリングするケース(例:モーダル内のドロップダウン)では、イベント伝搬はReactツリーに従います。ドロップダウン項目のクリックはモーダル→アプリルートへとバブリングします。
function ModalWithDropdown() {
return createPortal(
<Modal>
<Dropdown>
<DropdownItem onClick={handleAction} />
</Dropdown>
</Modal>,
modalRoot
);
}
z-index管理にはPortalStackコンポーネントを使用します。現在の深さを追跡し、子ポータルは親のz-indexを継承してインクリメントすることで、オーバーレイの競合を防止します。
コンテキストと状態管理
ポータルはReactツリー上の位置を保持するため、Context APIは自然に動作します。ポータル経由でレンダリングされた子要素は、ポータルの呼び出し元より上のプロバイダーにアクセスできます。
function ConnectedPortal({ children, contextOverrides }) {
return (
<ThemeProvider value={contextOverrides.theme}>
{createPortal(children, document.body)}
</ThemeProvider>
);
}
ポータル間の通信には、コールバックをコンテキスト経由で渡すか、共有のZustandストアを使用します。ストアは特別な配線なしにどのポータルからもアクセス可能で、複数ポータルの協調動作に最もシンプルな解決策を提供します。
アニメーション統合
ポータルの終了アニメーションは独特の課題があります。createPortalは親がアンマウントされると即座にDOMノードを削除するため、AnimatePresenceでアンマウントのタイミングを制御する必要があります。
import { AnimatePresence, motion } from "framer-motion";
function AnimatedModal({ isOpen, onClose, children }) {
return createPortal(
<AnimatePresence>
{isOpen && (
<motion.div
className="overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<motion.div
className="modal"
initial={{ scale: 0.9 }}
animate={{ scale: 1 }}
exit={{ scale: 0.9 }}
>
{children}
<button onClick={onClose}>Close</button>
</motion.div>
</motion.div>
)}
</AnimatePresence>,
document.body
);
}
AnimatePresenceは終了アニメーションの間DOMノードを保持し、アニメーション完了後に削除します。
アクセシビリティパターン
ポータルでは入念なアクセシビリティ対応が必要です。フォーカストラップ、フォーカス復帰、適切なARIA属性が不可欠です。
function useA11yPortal(triggerRef, isOpen) {
const previousFocus = useRef(null);
useEffect(() => {
if (isOpen) {
previousFocus.current = document.activeElement;
} else if (previousFocus.current) {
previousFocus.current.focus();
}
}, [isOpen]);
}
| 属性 | 目的 |
|---|---|
role="dialog" | 要素をダイアログとして識別 |
aria-modal="true" | 外部コンテンツが非アクティブであることを示す |
aria-hidden | 背景コンテンツに適用 |
aria-labelledby | ダイアログのタイトルを参照 |
モーダル表示中はポータルコンテナの兄弟要素すべてにaria-hidden="true"を設定します。inert属性と組み合わせることで、最も堅牢なアクセシビリティを実現できます。
パフォーマンス
ポータルコンテナがアプリルートから遠いDOM位置にある場合、レイアウトスラッシングが発生する可能性があります。ポータルの子要素は常にuseMemoでラップし、親コンポーネントが更新されてもポータル内部が不必要な再レンダリングを受けないようにします。
function App() {
const memoizedModal = useMemo(
() => (
<Modal onClose={handleClose}>
<ExpensiveContent />
</Modal>
),
[handleClose]
);
return (
<>
<MainContent />
{showModal && memoizedModal}
</>
);
}
ポータルのkeyプロップを変更すると、Reactはポータルサブツリー全体をアンマウントして再マウントします。これはインラインレンダリングよりも高コストなため、ホットパスで変化するキーをポータルコンテナに使用しないでください。
まとめ
ポータルはモーダルだけのための機能ではありません。ツールチップ、ドロップダウン、通知システムなど、親のCSSやDOMの制約から抜け出したいあらゆるUIに適用できます。名前付きターゲット、ネスト管理、アニメーション統合、アクセシビリティフックを組み合わせることで、複雑なオーバーレイ要件を処理しつつパフォーマンスとアクセシビリティを両立した、再利用可能なポータル抽象化レイヤーを構築できます。
