旧ブログ(ISSEN)から移行しました

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

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

杉田侑祐
杉田侑祐11分で読めます

はじめに

この記事の概要

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

対象読者

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

実装前に

開発環境

  • React 18.0.28
  • Next.js 13.2.3
  • TypeScript 4.9.5
  • Tailwind CSS 3.3.3

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

今回の完成予定

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

要件は下記とします。

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

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

アクセシビリティの要件

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

実装

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

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

  • 【18行目〜43行目】ReactDOM.createPortal(18行目)を使用し、root配下(43行目)でレンダリングされるようにしています。
    これでスタッキングコンテキストに影響されなくなります。
  • 【20,26,34行目】オーバレイ、モーダル内のバツアイコン、モーダル内のキャンセルボタンにClickイベントのコールバック関数としてモーダルを閉じる関数(closeModal)を渡しています。
  • 【21行目】オーバーレイをクリックした際のイベントの伝達を止めています。
  • 【37行目】Clickイベントのコールバック関数として実行される処理関数を渡しています。
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 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のステートとそれを利用した、表示/非表示を行う関数の定義
  • 実行ボタンを押した際に実行される関数の定義
index.tsx
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 && <DeleteModal closeModal={closeModal} isOpen={isOpen} handleAction={handleDelete} />}
    </>
  )
}
 
export default ExamplePage

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

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

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

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

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

新たなコンポーネントへ切り出し

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

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コンポーネントのリファクタリング

  • ボタン内のテキスト、モーダルのタイトル部分、モーダル内のテキストをpropsで受け取るようにしました。
  • 【7~13,17,29,49行目】ボタンの背景色をstringenum型(ColorMap型)で定義し、渡されたstring(red,blue,green,yellow)に対してオブジェクト(colorClasses)の参照(29行目)を行い変数に格納し背景色を変更できるようにしています。
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"
 
// ボタンの色を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で呼び出しているコンポーネント名の変更も忘れず行います。

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を使用します。

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 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文を使用し、モーダル表示時に以下の処理を行います。

  • 【26~30行目】押されたキーがEscapeだった場合にモーダルを閉じる関数をhandleEscapePressとして定義
  • 【34行目】keydownイベントのコールバック関数としてhandleEscapePress関数を渡しEscapeキーを押した際にモーダルを閉じる機能を実装
  • 【36行目】背景固定として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ライブラリを使うのはどうだろうとなることも多いかと思います。
そういった方は一度自作してみるのも良いかもしれません。

この記事を書いた人

杉田侑祐
杉田侑祐

TOKOSのフロントエンドエンジニア兼UI/UXデザイナー。このブログではフロントエンドメインで投稿しています。HIPHOPとゲームが好きです✌️