【初学者向け】Reactで作るモーダルコンポーネント

はじめに

この記事の概要

こんにちは、株式会社TOKOSのスギタです!
今回は初学者の方へ向けReactでモーダルを作っていきたいと思います。少しアクセシビリティにも配慮したモーダルにしていますので最後まで見ていただけたらと幸いです。

対象読者

  • Reactを用いたWEB制作、WEB開発を行っている方

実装前に

開発環境

  • React 18.0.28
  • Next.js13.2.3
  • TypeScript 4.9.5
  • tailwind 3.3.3

今回はAppディレクトリではなくPagesディレクトリで行います。

今回の完成予定

今回の完成イメージは下記になります。

要件は下記とします。

コンポーネントに持たせる機能や見た目の要件

  • 実行する関数をpropsとして渡せる
  • モーダルのタイトル(テキスト)と実行ボタンの見た目(カラー,テキスト)をpropsで変えることができる
  • モーダル内のテキストはpropsとして渡す
  • スタッキングコンテキストに影響されない

アクセシビリティの要件

  • オーバーレイ箇所をクリックした際に閉じることができる
  • モーダルを開いた際に背景を固定する
  • 「Escapeキー」を押してモーダルを閉じることができる

実装

削除Modalコンポーネントの作成

モーダルコンポーネントの作成をします。
propsとしてモーダル表示/非表示を管理しているBooleanのステート(isOpen)とモーダルを非表示にする関数(closeModal)モーダル内のボタンを押した際に行う関数(handleAction)をpropsとして受け取れるようにしています。
またその他にも下記のことを行っています。

  • 【22行目〜59行目】またReactDOM.createPortal(22行目)を使用し、root配下(59行目)でレンダリングされるようにしています。
    これでスタッキングコンテキストに影響されなくなります。
  • 【26,36,46行目】オーバレイ、モーダル内のバツアイコン、モーダル内のキャンセルボタンにClickイベントのコールバック関数としてモーダルを閉じる関数(closeModal)を渡しています。
  • 【30行目】オーバーレイをクリックした際のイベントの伝達を止めています。
  • 【52行目】Clickイベントのコールバック関数として実行される処理関数を渡しています。
import { FC, ReactNode, memo, useEffect } from "react"
import { createPortal } from "react-dom"
import CloseIcon from "@/assets/CloseIcon.svg"
import { notoSansJp } from "@/components/layouts/DefaultLayout"

type Props = {
  closeModal: () => void
  handleAction: () => void
  isOpen: boolean
}

export const Modal: FC<Props> = memo((props) => {
  const {
    closeModal,
    handleAction,
    isOpen,
  } = props

 //早期リターン
 if (!isOpen) return null

  return createPortal(
  //オーバレイ
    <div
      className={`fixed inset-0 z-40 flex items-center justify-center bg-[rgb(0_0_0/0.6)]`}
      onClick={closeModal}
    >
      <div
        className="w-full max-w-[540px] rounded bg-white py-4"
        onClick={(e) => e.stopPropagation()}
      >
        <div className="flex w-full items-center justify-between border-b px-6 pb-4">
          <div className="gap-4">
            <p className="text-base sm:text-xl">〇〇の削除</p>
          </div>
          <button onClick={closeModal}>
            <CloseIcon width={24} height={24} className="fill-gray-500" />
          </button>
        </div>
        <div className="px-6 pt-8">
          <p>本当に削除しても良いですか?</p>
        </div>
        <div className="mt-8 flex justify-end gap-x-2 border-t px-4 pt-4">
          <button
            className="rounded border border-gray-500 px-4 py-2 text-gray-500"
            onClick={closeModal}
          >
            キャンセル
          </button>
          <button
            className={`rounded px-4 py-2 text-white`}
            onClick={handleAction}
          >
          削除
          </button>
        </div>
      </div>
    </div>,
    document.getElementById("__next")!,
  )
})

index.tsxの実装

では呼び出す側の実装をしていきます。
index.tsxでは下記のことを行います。

  • モーダル表示/非表示を管理するBooleanのステートとそれを利用した、表示/非表示を行う関数の定義
  • 実行ボタンを押した際に実行される関数の定義
import { useState } from "react"
import { DeleteModal } from "@/components/Modal"

const ExamplePage = () => {
  //モーダル表示/非表示を管理するBooleanのステート
  const [isOpen, setIsOpen] = useState(false)

  //非表示させる関数
  const closeModal = () => {
    setIsOpen(false)
  }

 //表示させる関数
  const openModal = () => {
    setIsOpen(true)
  }

 //実行ボタンを押した際に実行される処理
  const handleDelete = () => console.log("削除する処理")

  return (
    <>
      <div className="p-10">
        <button
          className="rounded bg-red-500 p-4 text-[#fff]"
          onClick={openModal}
        >
          削除する
        </button>
      </div>
      {isOpen && (
        <Modal
          closeModal={closeModal}
          isOpen={isOpen}
          handleAction={handleDelete}
        />
      )}
    </>
  )
}

export default ExamplePage

モーダル開くボタンのClickイベントのコールバック関数にopenModal関数を渡し,trueに更新されたタイミングでモーダルを表示するようにしています。
また先程作成したDeleteModalコンポーネントにpropsとして、モーダルを閉じる関数(closeModal)実行ボタン(削除ボタン)が押された際に行う関数(handleDelete)、表示/非表示の状態によって変わるBooleanを渡しています。

削除Modalコンポーネントのリファクタリング

ここから、より使いまわしの効くコンポーネントにするため、リファクタリングしていきます。

  • モーダルのタイトル(テキスト)と実行ボタンの見た目(カラー,テキスト)をpropsで変えることができる
  • モーダル内のテキストはpropsとして渡す

要件定義した上記2つを実現するためにはModalコンポーネントから削除というドメインを持つ部分をコンポーネントから切り離し新たにコンポーネントを作成しそのコンポーネントに削除というドメインをもたせるべきです。

新たなコンポーネントへ切り出し
ドメインを持つ部分をpropsで渡すようにしています。
今回でいうとボタン内のテキスト、モーダルのタイトル部分、モーダル内のテキスト、ボタンの背景色の部分になります。

import { FC, memo } from "react"
import { Modal } from "@/components/Modal"

type Props = {
  closeModal: () => void
  handleAction: () => void
  isOpen: boolean
}

export const DeleteModal: FC<Props> = memo((props) => {
  const { closeModal, handleAction, isOpen } = props

  return (
    <Modal
      title="〇〇削除"
      closeModal={closeModal}
      isOpen={isOpen}
      handleAction={handleAction}
      buttonText="削除"
      buttonColor="red"
    >
      <p>本当に削除しても良いですか?</p>
    </Modal>
  )
})


先程作成したModalコンポーネントのリファクタリング

  • ボタン内のテキスト、モーダルのタイトル部分、モーダル内のテキストをpropsで受け取るようにしました。
  • 【7~13,17,28,37,67行目】ボタンの背景色をstringのenum型(ColorMap型)で定義し、渡されたstring(red,blue,green,yellow)に対してオブジェクト(colorClasses)の参照(37行目)を行い変数に格納し背景色を変更できるようにしています。
import { FC, ReactNode, memo, useEffect } from "react"
import { createPortal } from "react-dom"
import CloseIcon from "@/assets/CloseIcon.svg"
import { notoSansJp } from "@/components/layouts/DefaultLayout"

//ボタンの色をenum型として定義、今回は4色
type ColorMap = "blue" | "green" | "yellow" | "red"
const colorClasses: Record<ColorMap, string> = {
  blue: "bg-blue-500",
  green: "bg-green-500",
  red: "bg-red-500",
  yellow: "bg-yellow-500",
}

type Props = {
  title: string
  buttonColor: ColorMap
  buttonText: string
  children: ReactNode
  closeModal: () => void
  handleAction: () => void
  isOpen: boolean
}

export const Modal: FC<Props> = memo((props) => {
  const {
    title,
    buttonColor,
    buttonText,
    children,
    closeModal,
    handleAction,
    isOpen,
  } = props

  //ボタンの色をenum型として受け取りオブジェクトへの参照を行っている
  const colorClass = colorClasses[buttonColor]

  if (!isOpen) return null

  return createPortal(
    <div
      className={`fixed inset-0 z-40 flex items-center justify-center bg-[rgb(0_0_0/0.6)] ${notoSansJp.className}`}
      onClick={closeModal}
    >
      <div
        className="w-full max-w-[540px] rounded bg-white py-4"
        onClick={(e) => e.stopPropagation()}
      >
        <div className="flex w-full items-center justify-between border-b px-6 pb-4">
          <div className="gap-4">
            <p className="text-base sm:text-xl">{title}</p>
          </div>
          <button onClick={closeModal}>
            <CloseIcon width={24} height={24} className="fill-gray-500" />
          </button>
        </div>
        <div className="px-6 pt-8">{children}</div>
        <div className="mt-8 flex justify-end gap-x-2 border-t px-4 pt-4">
          <button
            className="rounded border border-gray-500 px-4 py-2 text-gray-500"
            onClick={closeModal}
          >
            キャンセル
          </button>
          <button
            className={`rounded px-4 py-2 text-white ${colorClass}`}
            onClick={handleAction}
          >
            {buttonText}
          </button>
        </div>
      </div>
    </div>, 
    document.getElementById("__next")!,
  )
})

これでコンポーネントに持たせる機能や見た目の要件とアクセシビリティのオーバーレイクリック時にモーダルを閉じるというアクセシビリティ要件を満たしました。

またindex.tsxで呼び出しているコンポーネント名の変更も忘れず行います。

import { useState } from "react"
import { DeleteModal } from "@/components/DeleteModal"

const ExamplePage = () => {
  //モーダル表示/非表示を管理するBooleanのステート
  const [isOpen, setIsOpen] = useState(false)

  //非表示させる関数
  const closeModal = () => {
    setIsOpen(false)
  }

 //表示させる関数
  const openModal = () => {
    setIsOpen(true)
  }

 //実行ボタンを押した際に実行される処理
  const handleDelete = () => console.log("削除する処理")

  return (
    <>
      <div className="p-10">
        <button
          className="rounded bg-red-500 p-4 text-[#fff]"
          onClick={openModal}
        >
          モーダルを開く
        </button>
      </div>
      {isOpen && (
        <DeleteModal
          closeModal={closeModal}
          isOpen={isOpen}
          handleAction={handleDelete}
        />
      )}
    </>
  )
}

export default ExamplePage

ここから残りのアクセシビリティに関わる要件の実装に入っていきます。

アクセシビリティ要件の実装

残りのアクセシビリティ要件を確認します。

  • モーダルを開いた際に背景を固定する
  • 「Escapeキー」を押してモーダルを閉じることができる

ここからはDOMを直接いじるのでuseEffectを使用します。

import { FC, ReactNode, memo, useEffect } from "react"
import { createPortal } from "react-dom"
import CloseIcon from "@/assets/CloseIcon.svg"
import { notoSansJp } from "@/components/layouts/DefaultLayout"

~~~ 省略 ~~~

type Props = {
  title: string
  buttonColor: ColorMap
  buttonText: string
  children: ReactNode
  closeModal: () => void
  handleAction: () => void
  isOpen: boolean
}

export const Modal: FC<Props> = memo((props) => {
  const {
    title,
    buttonColor,
    buttonText,
    children,
    closeModal,
    handleAction,
    isOpen,
  } = props

  ~~~ 省略 ~~~

 //アクセシビリティに関する記述
 useEffect(() => {
    // エスケープキーが押されたときの処理
    const handleEscapePress = (event: KeyboardEvent) => {
      if (event.key === "Escape") {
        closeModal()
      }
    }

    if (isOpen) {
      // エスケープキーのイベントリスナーを追加
      window.addEventListener("keydown", handleEscapePress)
      //背景固定の記述
      document.body.style.overflow = "hidden"
    } else {
      document.body.style.overflow = "auto"
    }

  //クリーンアップ関数
    return () => {
      window.removeEventListener("keydown", handleEscapePress)
      document.body.style.overflow = "auto"
    }
  }, [isOpen, closeModal])

  if (!isOpen) return null

  return createPortal(
   ~~~ 省略 ~~~
  )
})

if文で使用しモダール表示時に以下の処理を行います。

  • 【34~38行目】「Escapeキー」を押したキーがEscapeだった場合モーダルを閉じる関数をhandleEscapePressとして定義
  • 【42行目】Keydownイベントのコールバック関数としてhandleEscapePress関数を渡し「Escapeキー」を押した際にモーダルを閉じる機能を実装
  • 【44行目】背景固定としてbodyタグにoverflow:hidden;を付与する

またクリーンアップ関数を忘れないようにしましょう。

これアクセシビリティ要件も満たすことができました。

全体のコード

index.tsx

import { useState } from "react"
import { DeleteModal } from "@/components/DeleteModal"

const ExamplePage = () => {
  const [isOpen, setIsOpen] = useState(false)

  const closeModal = () => {
    setIsOpen(false)
  }

  const openModal = () => {
    setIsOpen(true)
  }

  const handleDelete = () => console.log("削除する処理")

  return (
    <>
      <div className="p-10">
        <button
          className="rounded bg-red-500 p-4 text-[#fff]"
          onClick={openModal}
        >
          削除する
        </button>
      </div>
      {isOpen && (
        <DeleteModal
          closeModal={closeModal}
          isOpen={isOpen}
          handleAction={handleDelete}
        />
      )}
    </>
  )
}

export default ExamplePage

DeleteModal.tsx

import { FC, memo } from "react"
import { Modal } from "@/components/Modal"

type Props = {
  closeModal: () => void
  handleAction: () => void
  isOpen: boolean
}

export const DeleteModal: FC<Props> = memo((props) => {
  const { closeModal, handleAction, isOpen } = props

  return (
    <Modal
      title="〇〇削除"
      closeModal={closeModal}
      isOpen={isOpen}
      handleAction={handleAction}
      buttonText="削除"
      buttonColor="red"
    >
      <p>本当に削除しても良いですか?</p>
    </Modal>
  )
})

Modal.tsx

import { FC, ReactNode, memo, useEffect } from "react"
import { createPortal } from "react-dom"
import CloseIcon from "@/assets/CloseIcon.svg"
import { notoSansJp } from "@/components/layouts/DefaultLayout"

type ColorMap = "blue" | "green" | "yellow" | "red"
const colorClasses: Record<ColorMap, string> = {
  blue: "bg-blue-500",
  green: "bg-green-500",
  red: "bg-red-500",
  yellow: "bg-yellow-500",
}

type Props = {
  title: string
  buttonColor: ColorMap
  buttonText: string
  children: ReactNode
  closeModal: () => void
  handleAction: () => void
  isOpen: boolean
}

export const Modal: FC<Props> = memo((props) => {
  const {
    title,
    buttonColor,
    buttonText,
    children,
    closeModal,
    handleAction,
    isOpen,
  } = props

  const colorClass = colorClasses[buttonColor]

  useEffect(() => {
    const handleEscapePress = (event: KeyboardEvent) => {
      if (event.key === "Escape") {
        closeModal()
      }
    }

    if (isOpen) {
      window.addEventListener("keydown", handleEscapePress)
      document.body.style.overflow = "hidden"
    } else {
      document.body.style.overflow = "auto"
    }

    return () => {
      window.removeEventListener("keydown", handleEscapePress)
      document.body.style.overflow = "auto"
    }
  }, [isOpen, closeModal])

  if (!isOpen) return null

  return createPortal(
    <div
      className={`fixed inset-0 z-40 flex items-center justify-center bg-[rgb(0_0_0/0.6)] ${notoSansJp.className}`}
      onClick={closeModal}
    >
      <div
        className="w-full max-w-[540px] rounded bg-white py-4"
        onClick={(e) => e.stopPropagation()}
      >
        <div className="flex w-full items-center justify-between border-b px-6 pb-4">
          <div className="gap-4">
            <p className="text-base sm:text-xl">{title}</p>
          </div>
          <button onClick={closeModal}>
            <CloseIcon width={24} height={24} className="fill-gray-500" />
          </button>
        </div>
        <div className="px-6 pt-8">{children}</div>
        <div className="mt-8 flex justify-end gap-x-2 border-t px-4 pt-4">
          <button
            className="rounded border border-gray-500 px-4 py-2 text-gray-500"
            onClick={closeModal}
          >
            キャンセル
          </button>
          <button
            className={`rounded px-4 py-2 text-white ${colorClass}`}
            onClick={handleAction}
          >
            {buttonText}
          </button>
        </div>
      </div>
    </div>,
    document.getElementById("__next")!,
  )
})

さいごに

今回Reactでモーダルコンポーネントを作成してみました。
モーダルは考慮することが多いので、ライブラリ等を使うことが多いかと思います。
モーダルだけのためにUIライブラリを使うのはどうだろうとなることも多いかと思います。
そういった方は一度自作してみるのも良いかもしれません。