Portalを利用したModal
今回は、よく使うモーダルをReactのPortalで再利用可能なModalに置き換えるべく
実装してみました!
今回実装したリポジトリです↓
Portalのメリット
ReactのPortalには以下のメリットがあると思っています。
- スタイルが分離されるため、影響を受けなくなる
-
document.body
直下に移動するため、コンポーネント外にレンダリング可能- モーダルやトーストに適している
ということで、Portalを利用した再利用可能なModalを実装していきます!
方針
今回の進める方針としては、
- 共通コンポーネント作成
- PortalでModalを管理するProvider実装
- useModalの呼び出し
- 動作確認
で、TailwindCSS・Next.jsを使用しています。
共通コンポーネント作成
共通コンポーネント作成は一旦Skipで以下のようなものを使う想定で進めます。
スタイルはとりあえずTailwindに備わっているもので済ませ、
オーバーレイとモーダルのガワのみ作成しました。
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内で必要になる状態は
- モーダルの開閉
- モーダルの中身
- モーダルの
className
です。モーダルの中身とclassName
はopenModal
関数を通して渡されます。
また、モーダルが閉じる時には中身とclassName
はリセットしたいのでcloseModal
内でそれぞれリセット相当の状態に変更します。といった感じで実装しました。
onOpen
, onClose
といったイベントを作成したい場合は関数を通して渡せるようにするか、Contextに追加して実装する形になると思います。
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
に組み込みます。
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
ではモーダルの中身を渡します。
'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内のボタンを使って閉じることが多いので以下のような実装をサンプルとして用意しました。
import { Modal } from '~/components/Modal'
export default function ModalContent({ onClose }: { onClose: () => void }) {
return (
<Modal onClose={onClose}>
<div>I’m a modal dialog</div>
<button className='rounded border px-2 py-1' onClick={onClose}>
Close
</button>
</Modal>
)
}
動作確認
Next.jsでサーバを立ち上げて確認します。。。
無事、実装できました!
トーストも同じように実装することができます。表示する時間やフェードインといったトランジションやアニメーションをCSSで設定することで、ライブラリ等で実装されているトーストに近づけることもできると思います!
皆さんもReactの便利なAPIを使ってUIを作成していきましょう!
お疲れ様でした〜!
参考文献
Discussion