Reactでモーダルウィンドウを作成しました【React/createPortal】

JavaScript

Reactの練習で簡単なモーダルウィンドウを作成しました。
こういったモーダル関係の動きはChakra UIなどのコンポーネントライブラリを使うことが多いと思いますが、createPortalの使い方のサンプルとしてメモしておきます。

モーダル要素を</body>の直前にマウントする【createPotal】

モーダル要素など、親のCSSの影響を受けたくない要素は、</body>の直前に挿入することが多いです。

とはいえ、<body>はReactが操作できるDOMの範囲外にあります。そんな時に使えるのがReactDOM.createPortal というフックです。

以下は、モーダルコンポーネントを本来の場所ではなく、<body>タグ直下にマウントさせている例です。

createPotalは、上の図のように、指定したDOMに別の子要素をマウントできます。
構文は次の通りになります。

createPortal(<子要素>, <DOM要素>);

使用するには、ReactDomのcreatePortalをインポートしておきます。上記の構文は、<子要素><DOM要素>にマウントされます。
createPotalはコンポーネントにして、子要素をprops.childrenで受け取ると、汎用性が高く使い回しができます。

以下は、<body>の最後にprops.childrenで渡ってきた要素をマウントするコードです。

import { createPortal } from "react-dom";

// createPortal
const ModalPortal = ({ children }) => {
 // マウントするDOMをbodyに指定する
  const target = document.body;
  return createPortal(children, target);
};

// modalElement
const ModalElement = () => {
  return (
    <>
        <ModalPortal>
          <div>
           子要素としてprops.childrenに渡す
          </div>
        </ModalPortal>
    </>
  );
};

上記が基本的な使い方になります。

createPortalを使ってモーダルウィンドウを作成する(デモ)

createPortalを使ってモーダルウィンドウを作成しました。同一ページに複数のモーダルを設置できて、画像とテキストの表示に対応しています。

完成の動きはCodeSandboxで確認できます。

コード全体

App.tsx

import "./styles.css";
import { useState, useEffect } from "react";
import ModalBtn from "./components/ModalBtn";
import ModalElement from "./components/ModalElement";

// APP
export default function App() {
  // モーダルが表示されている状態を管理
  const [modalOpen, setModalOpen] = useState(false);
  // モーダルの名前を管理
  const [modalName, setModalName] = useState("");
  // 読み込みの完了を管理
  const [readComplete, setReadComplete] = useState(false);

  // モーダルが表示されたらbody要素にクラスを追加
  useEffect(() => {
    if (modalOpen) {
      document.body.classList.add("is_modal");
    } else {
      document.body.classList.remove("is_modal");
    }
  }, [modalOpen]);

  // モーダルボタンをクリック(イベント)
  const modalClick = (e: any) => {
    const { target } = e;

    setModalOpen((prev) => !prev);
    setReadComplete(false);
    // eがボタンではない場合、処理を中止
    if (!(target instanceof HTMLButtonElement)) {
      return;
    }
    // 表示するモーダルを紐付け
    if (target.name) {
      setModalName(target.name);
    } else {
      setModalName("");
    }
  };

  return (
    <div className="App">
      <h1>モーダルウィンドウ</h1>
      <ModalBtn modalBtnName="modal1" modalClick={modalClick}>
        モーダル1(画像)
      </ModalBtn>
      <ModalElement
        targetName="modal1"
        modalOpen={modalOpen}
        modalName={modalName}
        modalClick={modalClick}
        readComplete={readComplete}
      >
        <img
          src="/images/sample1.jpg"
          alt=""
          onLoad={() => setReadComplete(true)}
        />
      </ModalElement>

      <ModalBtn modalBtnName="modal2" modalClick={modalClick}>
        モーダル2(テキスト)
      </ModalBtn>
      <ModalElement
        targetName="modal2"
        modalOpen={modalOpen}
        modalName={modalName}
        modalClick={modalClick}
      >
        <p>テキストテキストテキストテキストテキストテキスト</p>
      </ModalElement>
    </div>
  );
}

components/ModalPortal.tsx
createPortalフックでbodyにモーダル要素を追加します。

import { FC } from "react";
import { createPortal } from "react-dom";

type TypeModalPortal = {
  children: React.ReactChild;
};
const ModalPortal: FC<TypeModalPortal> = ({ children }) => {
  const target = document.body;
  return createPortal(children, target);
};

export default ModalPortal;

components/Modal.tsx

モーダル要素のコンテンツ内容です。props.childrenでラップされた要素を表示します。画像は読み込みが完了するまで「…Loading」を表示させています。

import { FC } from "react";
// モーダル要素
type ModalType = {
  modalClick: (event: any) => void;
  readComplete?: boolean;
};
const Modal: FC<ModalType> = ({ children, modalClick, readComplete }) => {
  console.log(readComplete);

  return (
    <>
      <div className="modal_content">
        {children}
        {readComplete === undefined || readComplete ? (
          ""
        ) : (
          <p className="loading">loading...</p>
        )}
        <div className="close_btn">
          <span onClick={modalClick}></span>
        </div>
      </div>
      <div className="overlay" onClick={modalClick}></div>
    </>
  );
};

export default Modal;

components/ModalElement.tsx
モーダル要素の外枠です。ModalPortalとModalコンポーネントを内包します。

import { FC } from "react";
import Modal from "./Modal";
import ModalPortal from "./ModalPortal";

type ModalElementType = {
  targetName: string;
  modalOpen: boolean;
  modalName: string;
  modalClick: (event: any) => void;
  readComplete?: boolean;
};

// モーダル外枠
const ModalElement: FC<ModalElementType> = ({
  children,
  targetName,
  modalOpen,
  modalName,
  modalClick,
  readComplete
}) => {
  return (
    <>
      {modalOpen && modalName === targetName ? (
        <ModalPortal>
          <Modal modalClick={modalClick} readComplete={readComplete}>
            {children}
          </Modal>
        </ModalPortal>
      ) : (
        ""
      )}
    </>
  );
};

export default ModalElement;

conponents/ModalBtn.tsx

モーダルを表示するトリガーとなるボタンです。

import { FC } from "react";

// モーダル要素
type ModalBtnType = {
  modalBtnName: string;
  modalClick: (event: any) => void;
};

const ModalBtn: FC<ModalBtnType> = ({ children, modalBtnName, modalClick }) => {
  return (
    <>
      <button name={modalBtnName} onClick={modalClick}>
        {children}
      </button>
    </>
  );
};

export default ModalBtn;

モーダルの設定方法

ModalBtn(ボタン)とModalElement(モーダル)コンポーネントにいくつか設定が必要です。
<モーダル名>はボタンとモーダル、両方に同じユニークな名前を設定します。

モーダルに画像を設定する場合

モーダルに画像を設定する場合、useStateで管理しているreadCompletesetReadCompleteを設定します。これらは画像の読み込みの完了をbooleanで管理します。

読み込みが完了するまで、モーダルにはLoadingの文字を表示します。画像の読み込みの完了を管理する必要がなければ、記述は必要ありません。

App.tsx

<ModalBtn modalBtnName="<モーダル名>" modalClick={modalClick}>
  ボタンに表示するテキスト
</ModalBtn>
<ModalElement
  targetName="<モーダル名>"
  modalOpen={modalOpen}
  modalName={modalName}
  modalClick={modalClick}
  // 画像の場合、読み込み完了のフラグを管理するreadCompleteを設定する
  readComplete={readComplete} 
>
  <img
    src="/images/sample1.jpg"
    alt=""
   // 画像の読み込みが完了したらフラグをtrueにする
    onLoad={() => setReadComplete(true)} 
  />
</ModalElement>

モーダルにテキストを設定する場合

モーダルにテキストを設定する際、readCompletesetReadCompleteの値は必要ないので記述はなくてもOKです。

App.tsx

<ModalBtn modalBtnName="<モーダル名>" modalClick={modalClick}>
  モーダル2(テキスト)
</ModalBtn>
<ModalElement
  targetName="<モーダル名>"
  modalOpen={modalOpen}
  modalName={modalName}
  modalClick={modalClick}
>
  <p>テキストテキストテキストテキストテキストテキスト</p>
</ModalElement>

まとめ

今回、createPortalの使い方を調べました。モーダルをサンプルにしたので、<body>に子要素をマウントしましたが、別のコンポーネントにマウントしたい時にも使えます。

機会があったら使ってみようと思います!