Reactにおけるグローバルモーダルの実装とPortalsの活用

コンポーネントツリーの深層でダイアログやモーダルを表示する場合、親要素からの制約により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>
  );
}

タグ: React dom-portal z-index-management modal-pattern frontend-architecture

10月9日 05:10 投稿