コンポーネントツリーの深層でダイアログやモーダルを表示する場合、親要素からの制約によりZインデックスの衝突やスタイリングの干渉が発生しやすい。これを防ぐためには、Reactの仮想DOMツリーとは独立したグローバルなDOMノードを確保し、そこに必要なUIコンポーネントを転送するアーキテクチャが採用される。React公式が推奨する`ReactDOM.createPortal`を用いることで、宣言的な記述ながら物理的なDOM配置を分離でき、階層管理の複雑さを解消できる。
グローバルマネージャーの実装例
以下は、手動でのDOM追加ではなくReact Hooksを使用してライフサイクルを管理し、可変長の子コンポーネントを動的にレンダリングする現代の実装パターンである。変数名や内部ロジックを再設計し、不要な外部依存を除いて軽量な構成に変更している。
import { useState, useRef, useEffect, useCallback, createElement, Fragment } from 'react';
const OVERLAY_ROOT_ID = 'app-portals-root';
export function PortalDialog({ isVisible, config }) {
const targetNode = useRef(null);
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
if (!targetNode.current) {
const rootNode = document.getElementById(OVERLAY_ROOT_ID);
if (!rootNode) {
const newNode = document.createElement('div');
newNode.id = OVERLAY_ROOT_ID;
document.body.appendChild(newNode);
}
targetNode.current = document.getElementById(OVERLAY_ROOT_ID);
}
setIsMounted(true);
return () => {
setIsMounted(false);
if (targetNode.current?.parentNode) {
targetNode.current.parentNode.removeChild(targetNode.current);
targetNode.current = null;
}
};
}, []);
if (!isVisible || !isMounted) return null;
const handleDismiss = useCallback(() => {
if (typeof config.dismissCallback === 'function') {
config.dismissCallback();
}
}, [config.dismissCallback]);
const sharedContext = {
...config.extraProps,
onClosed: handleDismiss
};
const renderedChildren = Array.isArray(config.childrenList)
? config.childrenList.map((Component, index) =>
createElement(Component, { ...sharedContext, key: `dyn-ch-${index}` })
)
: null;
return (
<Fragment>
<div className="global-backdrop" onClick={handleDismiss} />
<div className="dialog-surface">
<div className="dialog-header">{config.dialogTitle}</div>
<div className="dialog-content">{renderedChildren}</div>
</div>
</Fragment>
);
}
オーバーレイ用スタイル定義
固定配置によるスクロール追従を防ぎ、画面上部に重なるように設計する。`inset`プロパティを使用した簡潔な記法を採用している。
.global-backdrop {
position: fixed;
inset: 0;
background-color: rgba(15, 23, 42, 0.6);
z-index: 10000;
}
.dialog-surface {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: #fff;
border-radius: 8px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
min-width: 320px;
max-width: 90vw;
z-index: 10001;
}
.dialog-header {
padding: 1rem;
border-bottom: 1px solid #e5e7eb;
font-weight: 600;
}
.dialog-content {
padding: 1.5rem;
}
呼び出し側の利用パターン
親コンポーネントから状態フラグを制御し、レンダリングしたいコンポーネント群やコールバックを渡す形式で動作させる。イベント伝達やデータ受け渡しは、`sharedContext`経由で子側へバインドされるため、明示的な購読パターンなしに直接的な操作が可能になる。
import { useState } from 'react';
import { PortalDialog } from './PortalDialog';
import FormPanel from './FormPanel';
import AlertMessage from './AlertMessage';
function MainView() {
const [showDialog, setShowDialog] = useState(false);
const [selectedPanel, setSelectedPanel] = useState(FormPanel);
const openHandler = () => {
setSelectedPanel(Math.random() > 0.5 ? FormPanel : AlertMessage);
setShowDialog(true);
};
const closeHandler = () => setShowDialog(false);
return (
<div>
<button onClick={openHandler}>ダイアログ起動</button>
<PortalDialog
isVisible={showDialog}
config={{
dialogTitle: '確認パネル',
dismissCallback: closeHandler,
extraProps: { initialData: { id: 1 } },
childrenList: [selectedPanel]
}}
/>
</div>
);
}