「JavaScript」カテゴリーのロゴ

【React / Next.js】createPortalでmodalコンポーネントを作る

やりたいこと

モーダルは画面最前面に表示させることの多い性質上、bodyタグ直下にレンダリングしたいことがよくある。

そこで親コンポーネントより外にDOMをレンダリングできるReact Portalを使い、モーダルコンポーネントを実装してみる。

実装方法

1. モーダルの雛形を作る

import { type ReactNode } from "react";

const Modal = ({ children }: { children: ReactNode }) => {
  return (
    <div>
      <div>
        <button type="button" aria-label="モーダルを閉じる">
          ×
        </button>
        {children}
      </div>
    </div>
  );
};

export default Modal;

まずはpropsで渡されたchildrenと、閉じるボタンを描画する簡素なコンポーネントを作る。

- import { type ReactNode } from "react";
+ import { type ReactNode, useState } from "react";

 const Modal = ({
   children,
+   buttonText,
+   canCloseByClickingBackground = true,
 }: {
   children: ReactNode;
+   buttonText: string;
+   canCloseByClickingBackground?: boolean;
 }) => {
+   const [isOpened, setIsOpened] = useState(false);
+
+   const open = () => setIsOpened(true);
+   const close = () => setIsOpened(false);
+
+   if (!isOpened) {
+     return (
+       <button type="button" onClick={open}>
+         {buttonText}
+       </button>
+     );
+   }
+
   return (
     <div>
       <div>
         <button
           type="button"
           aria-label="モーダルを閉じる"
 +           onClick={close}
         >
           ×
         </button>
         {children}
       </div>
+       {canCloseByClickingBackground && <div onClick={close} />}
     </div>
   );
 };

 export default Modal;
  • ボタンをクリックするとモーダルが開く
  • モーダル内のxボタンをクリックするとモーダルが閉じる

上記要件を満たすように追記。

canCloseByClickingBackgroundtrueの時は、背景をクリックしてもモーダルが閉じるようにする。

2. createPortalでレンダリング

 import { type ReactNode, useState } from "react";
+ import { createPortal } from "react-dom";
-   return (
+   const elmModal = (
     <div>
       <div>
         <button
           type="button"
           aria-label="モーダルを閉じる"
           onClick={close}
         >
           ×
         </button>
         {children}
       </div>
       {canCloseByClickingBackground && <div onClick={close} />}
     </div>
   );

+   return createPortal(elmModal, document.body);
 };

 export default Modal;

createPortalを使い、bodyタグ直下にモーダルをレンダリングさせる。

第1引数にはレンダリングしたい要素を、第2引数にはレンダリング先のノードを指定。

3. スタイルの設定

.wrapper {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 2;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 100%;
  contain: content;
}

.content {
  position: relative;
  z-index: 1;
  box-sizing: border-box;
  width: 70vw;
  max-width: 700px;
  max-height: 90vh;
  padding: 40px;
  overflow-y: auto;
  background-color: #fff;
  border-radius: 5px;
  animation: anim-modal 0.5s ease;
  will-change: transform, opacity;
}

@keyframes anim-modal {
  from {
    opacity: 0;
    transform: translateY(15px);
  }
}

.btnClose {
  position: absolute;
  top: 5px;
  right: 10px;
  padding: 0;
  font-size: 2.7rem;
  font-weight: bold;
  line-height: 1;
  color: #bbb;
  cursor: pointer;
  background-color: transparent;
  border: none;
}

.background {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: rgba(0 0 0 / 50%);
}

@media screen and (max-width: 768px) {
  .content {
    width: 96vw;
    padding: 30px 25px;
  }
}

あとはモーダルっぽいUIにしたいので、modal.module.cssを作成。

 import { type ReactNode, useState } from "react";
 import { createPortal } from "react-dom";
+ import styles from "../styles/modal.module.css";
   const elmModal = (
-     <div>
-+     <div className={styles.wrapper}>
+-       <div>
-+       <div className={styles.content}>
+         <button
+           className={styles.btnClose}
           type="button"
           aria-label="モーダルを閉じる"
           onClick={close}
         >
           ×
         </button>
         {children}
       </div>
-       {canCloseByClickingBackground && <div onClick={close} />}
+       {canCloseByClickingBackground && (
+         <div className={styles.background} onClick={close} />
+       )}
     </div>
   );

   return createPortal(elmModal, document.body);
 };

 export default Modal;

ちなみにCSSは当サイトのCSSコピペツールを利用した。

4. コンポーネントを呼び出す

import Modal from "./modal";

const SomeComponent = () => {
  return (
    <Modal buttonText="モーダルを開く">
      <div>
        <p>モーダル内のコンテンツです。</p>
        <p>モーダル内のコンテンツです。</p>
        <p>モーダル内のコンテンツです。</p>
        <p>モーダル内のコンテンツです。</p>
        <p>モーダル内のコンテンツです。</p>
        <p>モーダル内のコンテンツです。</p>
        <p>モーダル内のコンテンツです。</p>
      </div>
    </Modal>
  );
};

export default SomeComponent;

あとは今回実装したコンポーネントを呼び出す。モーダル内に表示したい要素をchildrenとして渡せばOK。

無事モーダルが描画された

無事モーダルが表示され、

bodyタグ直下の最後尾にモーダルの要素がレンダリングされていた

bodyタグ直下の最後尾にレンダリングされていた 🎉

5. テスト実装

- <div className={styles.wrapper}>
-+ <div className={styles.wrapper} data-testid="modal-wrapper">
import Modal from "./modal";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

const DUMMY_MODAL_TEXT = "ダミーのテキストです。";
const DUMMY_BUTTON_TEXT = "ダミーのボタンです";

describe("Modalコンポーネント", () => {
  test("「開く」ボタンをクリックするとモーダルが開く", async () => {
    render(
      <Modal buttonText={DUMMY_BUTTON_TEXT}>
        <p>{DUMMY_MODAL_TEXT}</p>
      </Modal>
    );
    userEvent.click(screen.getByText(DUMMY_BUTTON_TEXT));

    expect(await screen.findByTestId("modal-wrapper")).toBeInTheDocument();
    expect(await screen.findByText(DUMMY_MODAL_TEXT)).toBeInTheDocument();
  });

  test("xボタンをクリックするとモーダルが閉じる", async () => {
    render(
      <Modal buttonText={DUMMY_BUTTON_TEXT}>
        <p>{DUMMY_MODAL_TEXT}</p>
      </Modal>
    );
    userEvent.click(screen.getByText(DUMMY_BUTTON_TEXT));
    userEvent.click(await screen.findByText("×"));

    expect(await screen.findByTestId("modal-wrapper")).not.toBeInTheDocument();
  });
});

最低限のテストも実装し、無事通った。