Featured image of post React Portals:モーダルとオーバーレイの高度なパターン Featured image of post React Portals:モーダルとオーバーレイの高度なパターン

React Portals:モーダルとオーバーレイの高度なパターン

ReactのcreatePortal APIを活用したモーダル、ツールチップ、オーバーレイの高度なパターン。イベントバブリング、ネストポータル、アクセシビリティまで解説。

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に適用できます。名前付きターゲット、ネスト管理、アニメーション統合、アクセシビリティフックを組み合わせることで、複雑なオーバーレイ要件を処理しつつパフォーマンスとアクセシビリティを両立した、再利用可能なポータル抽象化レイヤーを構築できます。