【React】createPortalとContextによるモーダル実装

JavaScript

前回、【React】createPortalによるポータルの作成・利用方法でポータルの概要について紹介しました。
モーダルは代表的なポータルの応用例です。今回はポータルとContextを利用してモーダルを実装する方法について紹介します。

Contextの概要については【React】useContexでContextを参照する手順で紹介しています。

検証環境はnext 12.1.4react 18.0.0です。

下準備

準備内容
  • モーダルを呼び出すExampleコンポーネントの作成
  • PagesでExampleコンポーネントを呼び出す
  • Contextを作成するModalProviderの作成
  • ModalProviderを_app.tsxに組み込む

src/pages/_app.tsx

import "../../styles/globals.css";
import type { AppProps } from "next/app";
import { ToastProvider } from "src/components/shared/ModalProvider";

function MyApp({ Component, pageProps }: AppProps) {
  return (
    <ModalProvider>
      <Component {...pageProps} />
    </ModalProvider>
  );
}

export default MyApp;

src/pages/index.tsx

import type { NextPage } from "next";
import { Example } from "../components/Example";

const Home: NextPage = () => {
  return <Example />;
};

export default Home;

src/components/Example.tsx

import { FC } from "react";

export const Example: FC = () => {
  return (
    <div>Example</div>
  );
};

src/components/shared/ModalProvider.tsx

import { createContext, FC, ReactNode, useEffect, useState } from "react";
import { createPortal } from "react-dom";

type Props = {
  children: ReactNode;
};

const initailState = "";
export const ModalContext = createContext<string>(initailState);

const createElement = (): HTMLElement => {
  const el = document.createElement("div");
  el.setAttribute("id", "modal");
  return el;
};

export const ModalProvider: FC<Props> = ({ children }) => {
  const [showPortal, setShowPortal] = useState(false);

  useEffect(() => {
    // modalというidが付与されたdiv要素を探し、なければ作成。
    const el = document.querySelector<HTMLElement>("#modal") ?? createElement();

    // <div id="portal">をbodyの子要素に追加
    document.body.appendChild(el);
  }, []);

  // document参照エラーを防ぐため、マウント後にcreatePortalを実行する
  useEffect(() => {
    setShowPortal(true);
  }, []);

  if (!showPortal) {
    return null;
  }

  return (
    <ModalContext.Provider value={initailState}>
      {children}
      {createPortal(<h1>Hello Modal</h1>, document.getElementById("modal")!)}
    </ModalContext.Provider>
  );
};

Contextを利用したポータルの表示・非表示の制御方法

Contextを利用したポータル表示・非表示のアプローチは以下の通りです。

  • ProviderのローカルState(modalNode)をポータルとして描画する
  • modalNodeにコンポーネントがセットされればポータル表示、NULLがセットされればポータル非表示となる
  • ProviderでmodalNodeに値をセットする関数(OpenModal・CloseModal)を定義し、Contextに渡す
  • コンポーネントはContextから受け取ったOpenModalとCloseModalを利用してmodalNodeの値を制御する

src/components/Example.tsx

import { FC, useContext } from "react";
import { ModalExample } from "src/components/ModalExample";
import { ModalContext } from "src/components/shared/ModalProvider"

export const Example: FC = () => {
  const { openModal, closeModal } = useContext(ModalContext);

  const handleOpen = () => {
    openModal(<ModalExample onClose={closeModal} />);
  };

  return (
    <>
      <div>Example</div>
      <button onClick={handleOpen}>開く</button>
    </>
  );
};

src/components/shared/ModalProvider.tsx

import { createContext, FC, ReactNode, useEffect, useState } from "react";
import { createPortal } from "react-dom";

type Props = {
  children: ReactNode;
};

type ContextType = {
  openModal: (modal: ReactNode) => void;
  closeModal: () => void;
};

const createElement = (): HTMLElement => {
  const el = document.createElement("div");
  el.setAttribute("id", "modal");
  return el;
};

export const ModalContext = createContext<ContextType>({} as ContextType);

export const ModalProvider: FC<Props> = ({ children }) => {
  const [modalNode, setModalNode] = useState<ReactNode>();
  const [showModal, setShowModal] = useState(false);

  useEffect(() => {
    const el = document.querySelector<HTMLElement>("#modal") ?? createElement();
    document.body.appendChild(el);
  }, []);

  const openModal = (modal: ReactNode) => {
    setModalNode(modal);
  };

  const closeModal = () => {
    setModalNode(null);
  };

  useEffect(() => {
    setShowModal(true);
  }, []);

  if (!showModal) {
    return null;
  }

  return (
    <ModalContext.Provider value={{ openModal, closeModal}}>
      {children}
      {createPortal(modalNode, document.getElementById("modal")!)}
    </ModalContext.Provider>
  );
};

上記の例では、開くボタンを押すとmodalNode<h1>Hello Modal</h1>がセットされるためポータルが表示されます。一方閉じるボタンを押すとmodalNodenullがセットされるためポータルが非表示になります。

modalNodeにセットするコンポーネントがポータルとして扱われるため、『任意のコンポーネントをモーダルとして表示する』ということが実現できます。

この時点で以下のような画面が作成できます。

参考: カスタムフックを利用したリファクタリング

上記のコードはカスタムフックを利用することで見通しがよくなります。
div要素を作成する副作用をカスタムフックとして切り出すと以下のようになります。

カスタムフックの詳細解説は【React】カスタムフックの概要・メリット・使いどころで紹介しています。

src/lib/hooks/usePortal.ts

import { useEffect, useState } from "react";

const createElement = (id: string): HTMLElement => {
  const el = document.createElement("div");
  el.setAttribute("id", id);
  return el;
};

export const usePortal = (id: string) => {
  const [element, setElement] = useState<HTMLElement | null>(null);

  useEffect(() => {
    const el =
      document.querySelector<HTMLElement>(`#${id}`) ?? createElement(id);
    document.body.appendChild(el);
    setElement(el);
  }, []);

  return element;
};

src/components/shared/ModalProvider.tsx

import { createContext, FC, ReactNode, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { usePortal } from "src/lib/hooks/usePortal";

type Props = {
  children: ReactNode;
};

type ContextType = {
  openModal: (modal: ReactNode) => void;
  closeModal: () => void;
};

export const ModalContext = createContext<ContextType>({} as ContextType);

export const ModalProvider: FC<Props> = ({ children }) => {
  const [modalNode, setModalNode] = useState<ReactNode>();
  const [showModal, setShowModal] = useState(false);

  const modalElement = usePortal("modal");

  const openModal = (modal: ReactNode) => {
    setModalNode(modal);
  };

  const closeModal = () => {
    setModalNode(null);
  };

  useEffect(() => {
    setShowModal(true);
  }, []);

  if (!showModal) {
    return null;
  }

  if (!modalElement) {
    return null;
  }

  return (
    <ModalContext.Provider value={{ openModal, closeModal }}>
      {children}
      {createPortal(modalNode, modalElement)}
    </ModalContext.Provider>
  );
};

モーダルの最終的なコード例

以下のような仕様を満たすモーダルの実装例を紹介します。

モーダルの仕様
  • 開くボタンでモーダルが画面中央に表示される
  • モダール表示時、モーダル外は暗くなる
  • モーダル内の閉じるボタン、あるいはモーダル外のクリックでモーダルが閉じる

src/components/Example.tsx

import { FC } from "react";
import { useModal } from "src/lib/hooks/useModal";
+ import { ModalExample } from "src/components/ModalExample";

export const Example: FC = () => {
  const { openModal, closeModal } = useModal();

  const handleOpen = () => {
-     openModal(<h1>Hello Modal</h1>);
+     openModal(<ModalExample onClose={closeModal} />);
  };

- const hadleClose = () => {
-   closeModal();
- };

  return (
    <>
      <div>Example</div>
      <button onClick={handleOpen}>開く</button>
-     <button onClick={hadleClose}>閉じる</button>
    </>
  );
};

src/components/ModalExample.tsx

import React, { FC } from "react";
import styles from "styles/ModalExample.module.css";
import { Modal } from "src/components/shared/Modal";

type Props = {
  onClose: () => void;
};

export const ModalExample: FC<Props> = ({ onClose }) => {
  return (
    <Modal onClose={onClose}>
      <h1 className={styles.title}>Hello Modal</h1>
      <div className={styles.button} onClick={onClose}>
        閉じる
      </div>
    </Modal>
  );
};

styles/ModalExample.module.css

.title {
  text-align: center;
  padding: 20px;
}

.button {
  margin: 0 auto;
  width: 200px;
  max-width: 100%;
  padding: 20px 10px; /* 上下の20pxで高さの確保とテキストの上下中央よせを行う。左右10pxはテキストが横幅いっぱいに埋まるのを防ぐため */
  background-color: red;
  text-align: center;
  color: white;
  cursor: pointer;
}

src/components/shared/Modal.tsx

import React, { FC, ReactNode } from "react";
import styles from "styles/Modal.module.css";

type Props = {
  children: ReactNode;
  onClose: () => void;
};

export const Modal: FC<Props> = ({ children, onClose }) => {
  return (
    <div className={styles.container}>
      <div className={styles.mask} onClick={onClose}></div>
      <div className={styles.modal}>{children}</div>
    </div>
  );
};

styles/Modal.module.css

.container {
  display: flex;
  justify-content: center;
  align-items: center;
  height: 100vh;
  width: 100vw;
  position: fixed;
  top: 0;
  right: 0;
}

.mask {
  position: absolute;
  top: 0;
  right: 0;
  height: 100vh;
  width: 100vw;
  background: black;
  opacity: 0.5;
  z-index: 1;
}

.modal {
  background: white;
  width: 400px;
  height: 250px;
  z-index: 10;
}

実装結果は以下の通りです。

さいごに

Twitter(@nishina555)やってます。フォローしてもらえるとうれしいです!