React の Portal の使い方を具体例をまじえて解説【 TypeScript 】

ショウヘイ

どうも、ノマドクリエイターのショウヘイ(@shohei_creator)です。

ふーちゃん

ブログアシスタントのふーちゃんです。

React に限らず、 HTML をベタ書きした場合にも共通して言えることですが、子要素の style は、親要素の style による制限を受けます。 over-flow プロパティや z-idnex プロパティなど、表示の範囲や優先度に関する style が関わると、親要素からの制限を強く影響します。

React には、親要素の style の制限から子要素を逃すための機能として、 Portal (ポータル)が用意されています。 Portal を活用すれば、任意の DOM ノードに子要素をマウント(追加)できるようになります。

この記事では、 React の Portal の使い方について説明します。

目次

React の Portal とは

React の Portal は、親コンポーネントの DOM とは別の DOM ノードに対して、子のコンポーネントをマウントできる機能です。

React は、ビルドによって、 HTML や CSS も生成されます。そのため、『 HTML の階層構造』と『 CSS の style の優先度』の影響を受けることになります。

React の単方向データフローによるコンポーネント構造を活かしつつ、かつ HTML ・ CSS の影響から子コンポーネントを逃すための方法として、 Portal が導入されたのだと思います。

ふーちゃん

React の Portal の必要性って、分かるようで……ちょっと分からないです。

使いどころが想像しづらいですね。

ショウヘイ

具体例を見てみないと、必要性が分かりにくいよね。

順を追って説明していこう。


React の Portal の基本構文と使い方

React の Portal の実装例に入る前に、まずは基本構文と使い方を説明します。

React の Portal の基本構文は、 ReactDOM の createPortal メソッドに対して、引数としてマウントする要素(子コンポーネント)マウント先の DOM 要素を渡す形になっています。

ReactDOM.createPortal(child, container)

React の Portal を使う前の準備として、まずは親コンポーネントが設置されるであろう階層に、マウント先となる HTML タグを設置しておきます。

create-react-app コマンドを使って React アプリを作っているなら、 public フォルダの index.html に書かれている <div id=”root”></div> の隣に、マウント先となる HTML タグを設置すればいいです。

<!-- 「create-react-app」コマンドで生成されたReactアプリのindex.html -->

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1, shrink-to-fit=no"
    />
    <meta name="theme-color" content="#000000" />
    <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
    <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
    <title>React App</title>
  </head>

  <body>
    <noscript>
      You need to enable JavaScript to run this app.
    </noscript>
    <div id="root"></div>

    <!-- Portalを使ってマウントする予定のdivタグ -->
    <div id="mounted"></div>
  </body>
</html>

次に、 Portal によってマウントさせたいコンポーネントを作っていきます。

汎用性を高めるために、内部に createPortal メソッドを持つラップ用コンポーネントを用意しておきます。

ちなみに、ラップ用コンポーネントを用意するかどうかは任意です。不要なら、子コンポーネントの中で createPortal メソッドを使って構いません。

import React from "react";
import ReactDOM from "react-dom";

type Props = {
  children: React.ReactChild;
};

export const WrapPortal: React.VFC<Props> = ({ children }) => {
  const el = document.getElementById("mounted");
  return ReactDOM.createPortal(children, el);
};

マウントさせたい子コンポーネントを用意します。とりあえず、簡易構成にしました。

import React from "react";

export const Children: React.VFC = () => {
  return <p>子コンポーネント</p>;
};

App.tsx ファイルにて、先ほど作った WrapPortal.tsx ファイルと Children.tsx ファイルを import します。また、 WrapPortal タグの中に Children タグを置きます。

import { WrapPortal } from "./WrapPortal";
import { Children } from "./Children";

export default function App() {
  return (
    <WrapPortal>
      <Children />
    </WrapPortal>
  );
}

ビルドされた状態をディベロッパーツールで見てみると、 <div id=”mounted”>タグに対して、 ラップ用コンポーネントがマウントしていることが確認できます。

React の Potals を使った DOM ノードの挿入例
ふーちゃん

App コンポーネントの子コンポーネントは、本来なら <div id=”root”>タグにマウントされるはずですよね。

でも、ラップ用コンポーネントは、 <div id=”mounted”> タグの方にマウントされています!

ショウヘイ

コンポーネントの階層構造を無視して、親コンポーネントとは別の DOM に対して、子コンポーネントをマウントできる。

これが React の Portal の特徴だね。

React の Portal が必要になる実装例(モーダルウィンドウ)

React の Portal が活躍する場面は、親コンポーネントの style に over-flow プロパティや z-idnex プロパティなどが使われている実装です。

まずは、 React の Portal を使わない場合の具体例として、モーダルウィンドウと『高階層に固定された要素』を併用するページの実装を紹介します。

ここでいうところの『高階層に固定された要素』 は、グローバルメニュー・トップスクロールボタン・バナーなどのようなものです。 z-index プロパティの値が大きく、かつ positon プロパティに「 fixled 」が使われていることが特徴です。

import { Parent } from "./Parent";
import { FixedHigherLevel } from "./FixedHigherLevel";
import { Container } from "react-bootstrap";

export default function App() {
  return (
    <Container className="mt-4">
      {/* 親コンポーネントと同階層に、固定要素コンポーネントを設置*/}
      <Parent />
      <FixedHigherLevel />
    </Container>
  );
}
import React, { useState } from "react";
import { Alert, Button } from "react-bootstrap";
import { Modal } from "./Modal";

export const Parent: React.VFC = () => {
  const [show, setShow] = useState<boolean>(false);
  const handleClose = (): void => setShow(false);
  const handleShow = (): void => setShow(true);

  return (
    <Alert variant="primary" style={{ position: "relative", zIndex: 100 }}>
      <p>親コンポーネント</p>
      <p>z-index:100</p>
      <Button variant="success" onClick={handleShow}>
        モーダルウィンドウを開く
      </Button>
      <Modal show={show} handleClose={handleClose} />
    </Alert>
  );
};
import React, { useEffect, useRef } from "react";
import "./modal.css";

type Props = {
  show: boolean;
  handleClose: () => void;
};

export const Modal: React.VFC<Props> = ({ show, handleClose }) => {
  const overlayRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const dom = overlayRef.current;
    if (dom) {
      if (show) {
        dom.style.display = "flex";
      } else {
        dom.style.display = "none";
      }
    }
  }, [show]);

  return (
    <div className="overlay" ref={overlayRef}>
      <div className="content">
        <h1>モーダルウィンドウ</h1>
        <p>モーダルウィンドウのz-indexは、1000です。</p>
        <p>でも、固定コンポーネントよりも下に表示されてしまっています。</p>
        <button onClick={handleClose}>閉じる</button>
      </div>
    </div>
  );
};
import React from "react";
import { Alert } from "react-bootstrap";

export const FixedHigherLevel: React.VFC = () => {
  return (
    <Alert
      variant="warning"
      style={{ position: "fixed", right: "20px", bottom: "40px", zIndex: 200 }}
    >
      <p>固定コンポーネント</p>
      <p> z-index:200</p>
    </Alert>
  );
};

モーダルウィドウ( z-index:1000 )よりも、固定コンポーネント( z-index:200 )の方が優先して表示されることに注目してください。不思議に見えるかもしれませんが、 z-index プロパティの正しい挙動です。

z-index プロパティ は、要素間の表示順の絶対的な優先度を意味しません。実際の機能は、兄弟要素の間における優先度を決めるためのものです。

最初の比較は、親コンポーネント( z-index:100 )と固定コンポーネント( z-index:200 )でおこなわれています。この時点で、固定コンポーネントの方が優先して表示されることが確定します。

モーダルウィンドウの z-index プロパティは 1000 になっていますが、この優先度は、あくまでも『モーダルウィンドウの兄弟要素』に対して適用されるものです。

固定コンポーネントは、親コンポーネントよりも優先度が上です。たとえモーダルウィンドウの z-index プロパティの値が高かろうと、 固定コンポーネント の方が優先して表示されます。


ふーちゃん

z-index プロパティの値って、兄弟要素に対する優先度を意味するものなんですね。

てっきり、絶対的な優先度を調整できる CSS プロパティかと思っていました。

ショウヘイ

だからこそ、親コンポーネントの style の影響から逃れられる Portal が導入されたんだ。 

Portal を使って、先ほどのコードを書き直すと、こんな感じになる。

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1, shrink-to-fit=no"
    />
    <meta name="theme-color" content="#000000" />
    <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
    <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
    <title>React App</title>
  </head>

  <body>
    <noscript>
      You need to enable JavaScript to run this app.
    </noscript>
    <div id="root"></div>

    <!-- modalでマウントするためのdivタブを追加 -->
    <div id="modal"></div>
  </body>
</html>
// Modal用のラップコンポーネント

import React from "react";
import ReactDOM from "react-dom";

type Props = {
  children: React.ReactChild;
};

export const WrapPortal: React.VFC<Props> = ({ children }) => {
  const el = document.getElementById("modal");
  return ReactDOM.createPortal(children, el);
};
import React, { useState } from "react";
import { Alert, Button } from "react-bootstrap";
import { Modal } from "./Modal";

// WrapPortalをimport
import { WrapPortal } from "./WrapPortal";

export const Parent: React.VFC = () => {
  const [show, setShow] = useState<boolean>(false);
  const handleClose = (): void => setShow(false);
  const handleShow = (): void => setShow(true);

  return (
    <Alert variant="primary" style={{ position: "relative", zIndex: 100 }}>
      <p>親コンポーネント</p>
      <p>z-index:100</p>
      <Button variant="success" onClick={handleShow}>
        モーダルウィンドウを開く
      </Button>

      {/* WrapPortalのchildrenとしてModalを設置 */}
      <WrapPortal>
        <Modal show={show} handleClose={handleClose} />
      </WrapPortal>
    </Alert>
  );
};
ふーちゃん

固定コンポーネントよりも、モーダルウィンドウの方が上に表示されていますね。

ショウヘイ

モーダルウィンドウが親コンポーネントとは別の DOM にマウントしたから、親コンポーネントの z-index プロパティの影響を受けなくなった恩恵だね。

React の Portal を使った場合のモーダルウィンドウ
この記事を知り合いに共有する
URLをコピーする
URLをコピーしました!
目次
閉じる