🚀

Portalを利用したModal

2023/11/20に公開

今回は、よく使うモーダルをReactのPortalで再利用可能なModalに置き換えるべく
実装してみました!

今回実装したリポジトリです↓
https://github.com/YuukiHayashi0510/React-portal_practice

Portalのメリット

ReactのPortalには以下のメリットがあると思っています。

  • スタイルが分離されるため、影響を受けなくなる
  • document.body直下に移動するため、コンポーネント外にレンダリング可能
    • モーダルやトーストに適している

ということで、Portalを利用した再利用可能なModalを実装していきます!

方針

今回の進める方針としては、

  1. 共通コンポーネント作成
  2. PortalでModalを管理するProvider実装
  3. useModalの呼び出し
  4. 動作確認

で、TailwindCSS・Next.jsを使用しています。

共通コンポーネント作成

共通コンポーネント作成は一旦Skipで以下のようなものを使う想定で進めます。
スタイルはとりあえずTailwindに備わっているもので済ませ、
オーバーレイとモーダルのガワのみ作成しました。

~components/Modal.tsx
import { memo } from 'react'

type Props = React.PropsWithChildren<{ onClose: () => void }>

export const Modal = memo<Props>(({ children, onClose }) => {
  return (
    <div
      className='fixed left-0 top-0 z-10 flex h-full w-full items-center justify-center overflow-auto bg-gray-900 bg-opacity-50'
      onClick={onClose}
    >
      <div
        className='relative inline-block origin-top-left rounded-lg bg-white p-5 shadow-xl'
        onClick={(e) => e.stopPropagation()}
      >
        {children}
      </div>
    </div>
  )
})

Modal.displayName = 'Modal'

PortalでModalを管理するProvider実装

このProviderを用いて、Provider以下のコンポーネントにモーダルの開閉を行う関数を渡します。

Provider内で必要になる状態は

  1. モーダルの開閉
  2. モーダルの中身
  3. モーダルのclassName

です。モーダルの中身とclassNameopenModal関数を通して渡されます。
また、モーダルが閉じる時には中身とclassNameはリセットしたいのでcloseModal内でそれぞれリセット相当の状態に変更します。といった感じで実装しました。
onOpen, onCloseといったイベントを作成したい場合は関数を通して渡せるようにするか、Contextに追加して実装する形になると思います。

~/providers/ModalProvider.tsx
import React, { PropsWithChildren, ReactNode, createContext, useContext, useState } from 'react'
import { createPortal } from 'react-dom'

import Modal from '../components/Modal'

type Context = {
  isOpen: boolean
  openModal: (content: ReactNode, className?: string) => void
  closeModal: () => void
}

const ModalContext = createContext({} as Context)

export const ModalProvider = ({ children }: PropsWithChildren) => {
  const [isOpen, setIsOpen] = useState(false)
  const [content, setContent] = useState<ReactNode>(null)
  const [className, setClassName] = useState<string>('')

  const openModal: Context['openModal'] = (content, className) => {
    setClassName(className ?? '')
    setContent(content)
    setIsOpen(true)
  }

  const closeModal: Context['closeModal'] = () => {
    setClassName('')
    setContent(null)
    setIsOpen(false)
  }

  return (
    <ModalContext.Provider
      value={{
        isOpen,
        openModal,
        closeModal,
      }}
    >
      {children}
      {isOpen
        ? createPortal(
            <Modal onClose={closeModal} className={className}>
              {content}
            </Modal>,
            document.body,
          )
        : null}
    </ModalContext.Provider>
  )
}

export const useModal = () => useContext(ModalContext)

そして、完成したProviderをlayout.tsxに組み込みます。

app/layout.tsx
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
import { ModalProvider } from '~/providers/ModalProvider'

const inter = Inter({ subsets: ['latin'] })

export const metadata: Metadata = {
  title: 'Create Next App',
  description: 'Generated by create next app',
}

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang='en'>
      <body className={inter.className}>
        <ModalProvider>{children}</ModalProvider>
      </body>
    </html>
  )
}

useModalの呼び出し

以下のようにしてProvider以下のコンポーネントでuseModalを呼び出し、モーダルを操作する関数を渡します。openModalではモーダルの中身を渡します。

app/page.tsx
'use client'

import { useModal } from '~/providers/ModalProvider'
import ModalContent from './components/ModalContent'

export default function Home() {
  const { openModal, closeModal } = useModal()

  return (
    <main className='flex min-h-screen flex-col items-center justify-between p-24'>
      <button
        className='rounded border bg-white px-3 py-1'
        onClick={() => openModal(<ModalContent onClose={closeModal} />)}
      >
        モーダルを開く
      </button>
    </main>
  )
}

ModalContentの中身は以下のようになっています。
ModalではModal内のボタンを使って閉じることが多いので以下のような実装をサンプルとして用意しました。

app/components/ModalContent.tsx
import { Modal } from '~/components/Modal'

export default function ModalContent({ onClose }: { onClose: () => void }) {
  return (
    <Modal onClose={onClose}>
      <div>I&rsquo;m a modal dialog</div>
      <button className='rounded border px-2 py-1' onClick={onClose}>
        Close
      </button>
    </Modal>
  )
}

動作確認

Next.jsでサーバを立ち上げて確認します。。。


無事、実装できました!

トーストも同じように実装することができます。表示する時間やフェードインといったトランジションやアニメーションをCSSで設定することで、ライブラリ等で実装されているトーストに近づけることもできると思います!

皆さんもReactの便利なAPIを使ってUIを作成していきましょう!
お疲れ様でした〜!

参考文献

https://ja.react.dev/reference/react-dom/createPortal

Discussion