PortalとModal
この章では、ReactのcreatePortalを使用したモーダルの実装について学びます。
この章で学ぶこと
createPortalの仕組みと使いどころ- アクセシブルなモーダルの実装方法
- フォーカストラップとキーボード操作
- 確認ダイアログやトースト通知の実装
- イベントバブリングの注意点
この章では03章で作成したプロジェクトを使用します。npm run devで開発サーバーを起動し、コードを書きながら学習を進めましょう。
本章ではuseEffect(11章)とuseRef(12章)を活用します。これらのフックに不安がある場合は、先に該当の章を復習してください。
createPortalとは
createPortalは、子要素を親コンポーネントのDOM階層外にレンダリングする機能です。モーダル、ツールチップ、ドロップダウンなど、視覚的に親要素の外に表示したい場合に使用します。
なぜPortalが必要か
通常、Reactコンポーネントは親のDOM要素内にレンダリングされます。しかし、これには問題があります。
// 問題: CSSのoverflowやz-indexの影響を受ける
function Card() {
return (
<div style={{ overflow: 'hidden' }}>
<Modal /> {/* overflow: hiddenで切り取られてしまう */}
</div>
)
}
Portalを使用すると、DOM階層を超えてレンダリングできます。
Portalの基本
使い方
import { createPortal } from 'react-dom'
function Modal({ children }: { children: React.ReactNode }) {
// document.bodyの直下にレンダリング
return createPortal(
<div className="modal-overlay">
<div className="modal-content">
{children}
</div>
</div>,
document.body
)
}
createPortalの引数
createPortal(children, domNode, key?)
children: レンダリングするReact要素domNode: レンダリング先のDOM要素key: オプションのキー
モーダルの実装
基本的なモーダル
// components/Modal.tsx
import { createPortal } from 'react-dom'
import { useEffect } from 'react'
type ModalProps = {
isOpen: boolean
onClose: () => void
children: React.ReactNode
title?: string
}
export function Modal({ isOpen, onClose, children, title }: ModalProps) {
// ESCキーで閉じる
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose()
}
}
if (isOpen) {
document.addEventListener('keydown', handleEscape)
// スクロールを無効化
document.body.style.overflow = 'hidden'
}
return () => {
document.removeEventListener('keydown', handleEscape)
document.body.style.overflow = ''
}
}, [isOpen, onClose])
if (!isOpen) return null
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center"
role="dialog"
aria-modal="true"
aria-labelledby={title ? 'modal-title' : undefined}
>
{/* オーバーレイ */}
<div
className="absolute inset-0 bg-black/50"
onClick={onClose}
aria-hidden="true"
/>
{/* モーダルコンテンツ */}
<div className="relative z-10 bg-white rounded-lg shadow-xl max-w-md w-full mx-4 p-6">
{title && (
<h2 id="modal-title" className="text-xl font-bold mb-4">
{title}
</h2>
)}
{children}
</div>
</div>,
document.body
)
}
使用例
import { useState } from 'react'
import { Modal } from './components/Modal'
function App() {
const [isModalOpen, setIsModalOpen] = useState(false)
const handleOpenModal = () => {
setIsModalOpen(true)
}
const handleCloseModal = () => {
setIsModalOpen(false)
}
const handleConfirm = () => {
// 処理実行
setIsModalOpen(false)
}
return (
<div className="p-4">
<button
onClick={handleOpenModal}
className="px-4 py-2 bg-blue-500 text-white rounded"
>
モーダルを開く
</button>
<Modal
isOpen={isModalOpen}
onClose={handleCloseModal}
title="確認"
>
<p className="mb-4">この操作を実行しますか?</p>
<div className="flex gap-2 justify-end">
<button
onClick={handleCloseModal}
className="px-4 py-2 bg-gray-200 rounded"
>
キャンセル
</button>
<button
onClick={handleConfirm}
className="px-4 py-2 bg-blue-500 text-white rounded"
>
確認
</button>
</div>
</Modal>
</div>
)
}
試してみよう: モーダルコンポーネントを作成
src/components/Modal.tsxを作成し、開閉機能とESCキーで閉じる機能を実装してみましょう。
ヒント
createPortalをreact-domからインポートisOpenがfalseの場合はnullを返すuseEffectでkeydownイベントを監視- クリーンアップで
removeEventListenerを呼ぶ
回答と解説
この回答では専用のコンテナ要素(modal-root)を使用していますが、本文で紹介したdocument.bodyでも動作します。専用コンテナを使う利点については、章末の「Portal用のコンテナ要素」セクションを参照してください。
<!-- index.html に追加 -->
<body>
<div id="root"></div>
<div id="modal-root"></div>
</body>
// src/components/Modal.tsx
import { createPortal } from 'react-dom'
import { useEffect } from 'react'
type ModalProps = {
isOpen: boolean
onClose: () => void
title?: string
children: React.ReactNode
}
export function Modal({ isOpen, onClose, title, children }: ModalProps) {
useEffect(() => {
if (!isOpen) return
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose()
}
}
document.addEventListener('keydown', handleKeyDown)
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.body.style.overflow = ''
}
}, [isOpen, onClose])
if (!isOpen) return null
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center"
role="dialog"
aria-modal="true"
>
<div
className="absolute inset-0 bg-black/50"
onClick={onClose}
aria-hidden="true"
/>
<div className="relative z-10 bg-white rounded-lg shadow-xl max-w-md w-full mx-4 p-6">
{title && <h2 className="text-xl font-bold mb-4">{title}</h2>}
{children}
<button
onClick={onClose}
className="absolute top-4 right-4 text-gray-400 hover:text-gray-600"
>
✕
</button>
</div>
</div>,
document.getElementById('modal-root')!
)
}
export default Modal
createPortalの第2引数にmodal-root要素を指定することで、DOMツリー上では#modal-rootの子としてレンダリングされます。ESCキーを押すか、オーバーレイをクリックするとモーダルが閉じます。
イベントバブリング
Portalの重要な特性として、イベントはReactツリーをバブリングするという点があります。
function Parent() {
const handleClick = () => {
console.log('Parent clicked')
}
return (
<div onClick={handleClick}>
<Child />
</div>
)
}
function Child() {
return createPortal(
<button onClick={() => console.log('Button clicked')}>
クリック
</button>,
document.body
)
}
// ボタンクリック時の出力:
// "Button clicked"
// "Parent clicked" ← DOMは別でもReactツリーでバブリング
Portalで作成した要素は、DOMツリーではdocument.bodyの子要素ですが、Reactのイベントシステムでは元の親コンポーネントの子として扱われます。これを理解しておかないと、意図しないイベント伝播が発生する可能性があります。
イベントの停止
必要に応じてstopPropagationでバブリングを止めます。
function Modal({ onClose, children }: ModalProps) {
return createPortal(
<div onClick={onClose}>
<div onClick={(e) => e.stopPropagation()}>
{children}
</div>
</div>,
document.body
)
}
アクセシビリティ対応
モーダルのアクセシビリティは重要です。本章前半では createPortal + <div role="dialog"> をベースに仕組みを学びましたが、本番コードではネイティブ <dialog> 要素や専用ライブラリの利用を推奨します。
ネイティブ <dialog> 要素
HTML 標準の <dialog> 要素は、モーダルの多くの機能をブラウザ側で実装してくれます。2022 年以降、全主要ブラウザでサポートされています。
import { useEffect, useRef } from 'react'
type DialogModalProps = {
isOpen: boolean
onClose: () => void
children: React.ReactNode
}
function DialogModal({ isOpen, onClose, children }: DialogModalProps) {
const dialogRef = useRef<HTMLDialogElement>(null)
useEffect(() => {
const dialog = dialogRef.current
if (!dialog) return
if (isOpen) {
dialog.showModal() // モーダルとして開く(フォーカストラップも自動)
} else if (dialog.open) {
dialog.close()
}
}, [isOpen])
return (
<dialog ref={dialogRef} onClose={onClose}>
{children}
</dialog>
)
}
<dialog> を showModal() で開くと、以下がすべて自動で実現されます。
- フォーカストラップ: Tab キーが
<dialog>の外に出ない - フォーカス復元: 閉じた後、開く前にフォーカスがあった要素に戻る
- Esc キーで閉じる: ブラウザが
closeイベントを発火 - top-layer 描画:
z-indexを気にせず常に最前面に表示 ::backdrop疑似要素: 背景のオーバーレイを CSS で装飾可能
なお、背景ページのスクロールは自動ではロックされません。html:has(dialog:modal) { overflow: hidden; } のような CSS を併用してください(前半の Modal 実装で body.style.overflow を操作したのと同じ役割です)。
dialog::backdrop {
background-color: rgba(0, 0, 0, 0.5);
}
showModal() と show() は別物です。show() はフォーカストラップや背景の inert 化を行わない非モーダル表示です。確認ダイアログのようにユーザー操作をブロックしたい場合は必ず showModal() を使ってください。
手書きフォーカストラップ(学習用)
以下は教育目的で useEffect + useRef を使った実装例です。<dialog> 要素を使わない場合の仕組みを理解するのに役立ちます。
本番環境では手書きの useFocusTrap ではなく、ネイティブ <dialog> か、focus-trap などの専用ライブラリ、または Radix UI / Headless UI のようなヘッドレスコンポーネントの利用を推奨します。手書きの実装は、非表示要素・disabled 要素・iframe・Portal をまたいだ要素など、実務で遭遇する多くのエッジケースをカバーしきれません。
フォーカストラップ
モーダルが開いている間、フォーカスをモーダル内に閉じ込めます。
import { useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
function useFocusTrap(isOpen: boolean) {
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!isOpen || !containerRef.current) return
const container = containerRef.current
const focusableElements = container.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
const firstElement = focusableElements[0]
const lastElement = focusableElements[focusableElements.length - 1]
// 最初の要素にフォーカス
firstElement?.focus()
const handleTab = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return
if (e.shiftKey) {
// Shift + Tab
if (document.activeElement === firstElement) {
e.preventDefault()
lastElement?.focus()
}
} else {
// Tab
if (document.activeElement === lastElement) {
e.preventDefault()
firstElement?.focus()
}
}
}
container.addEventListener('keydown', handleTab)
return () => container.removeEventListener('keydown', handleTab)
}, [isOpen])
return containerRef
}
// 使用例(ModalProps は前半「基本的なモーダル」の定義を再利用)
function AccessibleModal({ isOpen, onClose, children }: ModalProps) {
const containerRef = useFocusTrap(isOpen)
if (!isOpen) return null
return createPortal(
<div
ref={containerRef}
role="dialog"
aria-modal="true"
>
{children}
</div>,
document.body
)
}
ARIA属性
<div
role="dialog" // ダイアログであることを示す
aria-modal="true" // モーダルダイアログ
aria-labelledby="modal-title" // タイトル要素のID
aria-describedby="modal-desc" // 説明要素のID
>
<h2 id="modal-title">タイトル</h2>
<p id="modal-desc">説明文</p>
</div>
確認ダイアログの実装
よく使う確認ダイアログをカスタムフックで実装します。
// hooks/useConfirm.tsx
import { useState, useCallback, type ReactNode } from 'react'
import { Modal } from '../components/Modal'
type ConfirmOptions = {
title?: string
message: ReactNode
confirmText?: string
cancelText?: string
}
export function useConfirm() {
const [isOpen, setIsOpen] = useState(false)
const [options, setOptions] = useState<ConfirmOptions | null>(null)
const [resolvePromise, setResolvePromise] = useState<((value: boolean) => void) | null>(null)
const confirm = useCallback((opts: ConfirmOptions): Promise<boolean> => {
setOptions(opts)
setIsOpen(true)
return new Promise((resolve) => {
setResolvePromise(() => resolve)
})
}, [])
const handleConfirm = useCallback(() => {
setIsOpen(false)
resolvePromise?.(true)
}, [resolvePromise])
const handleCancel = useCallback(() => {
setIsOpen(false)
resolvePromise?.(false)
}, [resolvePromise])
const ConfirmDialog = useCallback(() => {
if (!options) return null
return (
<Modal isOpen={isOpen} onClose={handleCancel} title={options.title}>
<p className="mb-4">{options.message}</p>
<div className="flex gap-2 justify-end">
<button
onClick={handleCancel}
className="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300"
>
{options.cancelText ?? 'キャンセル'}
</button>
<button
onClick={handleConfirm}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
{options.confirmText ?? '確認'}
</button>
</div>
</Modal>
)
}, [isOpen, options, handleConfirm, handleCancel])
return { confirm, ConfirmDialog }
}
// 使用例
function DeleteButton({ onDelete }: { onDelete: () => void }) {
const { confirm, ConfirmDialog } = useConfirm()
const handleClick = async () => {
const confirmed = await confirm({
title: '削除の確認',
message: 'この項目を削除しますか?この操作は取り消せません。',
confirmText: '削除',
cancelText: 'やめる'
})
if (confirmed) {
onDelete()
}
}
return (
<>
<button
onClick={handleClick}
className="px-4 py-2 bg-red-500 text-white rounded"
>
削除
</button>
<ConfirmDialog />
</>
)
}
試してみよう: 確認ダイアログを実装
useConfirmフックを作成し、削除ボタンで使用してみましょう。Promiseを使って確認/キャンセルの結果を返すようにしてください。
ヒント
useStateで開閉状態とオプションを管理confirm関数でPromiseを返すsetResolver(() => resolve)でresolve関数を保存- 確認/キャンセル時に
resolver(true/false)を呼ぶ
回答と解説
// src/hooks/useConfirm.tsx
import { useState, useCallback } from 'react'
import { Modal } from '../components/Modal'
type ConfirmOptions = {
title?: string
message: string
confirmText?: string
cancelText?: string
}
export function useConfirm() {
const [isOpen, setIsOpen] = useState(false)
const [options, setOptions] = useState<ConfirmOptions | null>(null)
const [resolver, setResolver] = useState<((value: boolean) => void) | null>(null)
const confirm = useCallback((opts: ConfirmOptions): Promise<boolean> => {
setOptions(opts)
setIsOpen(true)
return new Promise((resolve) => {
setResolver(() => resolve)
})
}, [])
const handleConfirm = useCallback(() => {
setIsOpen(false)
resolver?.(true)
}, [resolver])
const handleCancel = useCallback(() => {
setIsOpen(false)
resolver?.(false)
}, [resolver])
const ConfirmDialog = () => {
if (!options) return null
return (
<Modal isOpen={isOpen} onClose={handleCancel} title={options.title}>
<p className="text-gray-600 mb-6">{options.message}</p>
<div className="flex gap-3 justify-end">
<button
onClick={handleCancel}
className="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300"
>
{options.cancelText ?? 'キャンセル'}
</button>
<button
onClick={handleConfirm}
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
>
{options.confirmText ?? '確認'}
</button>
</div>
</Modal>
)
}
return { confirm, ConfirmDialog }
}
// 使用例
function App() {
const [items, setItems] = useState([
{ id: 1, name: 'アイテム1' },
{ id: 2, name: 'アイテム2' }
])
const { confirm, ConfirmDialog } = useConfirm()
const handleDelete = async (item: { id: number; name: string }) => {
const confirmed = await confirm({
title: '削除の確認',
message: `「${item.name}」を削除しますか?`,
confirmText: '削除する',
cancelText: 'やめる'
})
if (confirmed) {
setItems(prev => prev.filter(i => i.id !== item.id))
}
}
const createDeleteHandler = (item: { id: number; name: string }) => () => {
handleDelete(item)
}
return (
<div className="p-8">
{items.map(item => (
<div key={item.id} className="flex justify-between p-2">
<span>{item.name}</span>
<button onClick={createDeleteHandler(item)}>削除</button>
</div>
))}
<ConfirmDialog />
</div>
)
}
export default App
useConfirmフックはPromiseを返すことで、awaitで確認結果を待つことができます。setResolver(() => resolve)のように関数をセットすることで、後からresolveを呼び出せるようにしています。
トーストNotification
Portalはトースト通知にも適しています。
// contexts/ToastContext.tsx
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
type ToastType = 'success' | 'error' | 'info'
type Toast = {
id: string
type: ToastType
message: string
}
type ToastContextType = {
showToast: (type: ToastType, message: string) => void
}
const ToastContext = createContext<ToastContextType | undefined>(undefined)
export function useToast() {
const context = useContext(ToastContext)
if (!context) {
throw new Error('useToast must be used within ToastProvider')
}
return context
}
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([])
const showToast = useCallback((type: ToastType, message: string) => {
const id = crypto.randomUUID()
setToasts(prev => [...prev, { id, type, message }])
// 3秒後に自動削除
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id))
}, 3000)
}, [])
const removeToast = useCallback((id: string) => {
setToasts(prev => prev.filter(t => t.id !== id))
}, [])
const createRemoveHandler = (id: string) => () => {
removeToast(id)
}
return (
<ToastContext.Provider value={{ showToast }}>
{children}
{createPortal(
<div className="fixed top-4 right-4 z-50 space-y-2">
{toasts.map(toast => (
<div
key={toast.id}
className={`px-4 py-3 rounded shadow-lg text-white flex items-center gap-2 ${
toast.type === 'success' ? 'bg-green-500' :
toast.type === 'error' ? 'bg-red-500' :
'bg-blue-500'
}`}
>
<span>{toast.message}</span>
<button
onClick={createRemoveHandler(toast.id)}
className="ml-2 hover:opacity-70"
>
✕
</button>
</div>
))}
</div>,
document.body
)}
</ToastContext.Provider>
)
}
// 使用例
function App() {
return (
<ToastProvider>
<SaveButton />
</ToastProvider>
)
}
function SaveButton() {
const { showToast } = useToast()
const handleSave = async () => {
try {
await saveData()
showToast('success', '保存しました')
} catch {
showToast('error', '保存に失敗しました')
}
}
return <button onClick={handleSave}>保存</button>
}
ツールチップ
Portalはツールチップの実装にも使えます。
import { useState, useRef } from 'react'
import { createPortal } from 'react-dom'
type TooltipProps = {
content: string
children: React.ReactNode
}
export function Tooltip({ content, children }: TooltipProps) {
const [isVisible, setIsVisible] = useState(false)
const [position, setPosition] = useState({ top: 0, left: 0 })
const triggerRef = useRef<HTMLSpanElement>(null)
const showTooltip = () => {
if (triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect()
setPosition({
top: rect.bottom + window.scrollY + 8,
left: rect.left + window.scrollX + rect.width / 2
})
}
setIsVisible(true)
}
const hideTooltip = () => {
setIsVisible(false)
}
return (
<>
<span
ref={triggerRef}
onMouseEnter={showTooltip}
onMouseLeave={hideTooltip}
onFocus={showTooltip}
onBlur={hideTooltip}
>
{children}
</span>
{isVisible && createPortal(
<div
className="absolute z-50 px-2 py-1 text-sm text-white bg-gray-800 rounded -translate-x-1/2"
style={{ top: position.top, left: position.left }}
role="tooltip"
>
{content}
<div className="absolute -top-1 left-1/2 -translate-x-1/2 border-4 border-transparent border-b-gray-800" />
</div>,
document.body
)}
</>
)
}
// 使用例
<Tooltip content="クリックしてコピー">
<button>コピー</button>
</Tooltip>
位置計算の注意点
getBoundingClientRect() で取得した座標は、表示したその瞬間のビューポート基準の値です。そのため、以下のケースで位置がずれる可能性があります。
- 表示中にウィンドウがスクロールされた:
scrollイベントで再計算が必要 - 表示中にウィンドウがリサイズされた:
resizeイベントまたはResizeObserverで再計算が必要 - トリガー要素自体のサイズが動的に変わる:
ResizeObserverで検知 - ビューポートの端に近い: ツールチップが画面外にはみ出す場合があるため、画面端判定と位置反転が必要
シンプルなツールチップなら表示中のスクロールを抑止する設計もありますが、要件が複雑なら Floating UI(旧 Popper.js)のような専用ライブラリを使うのが確実です。配置計算・オーバーフロー回避・矢印描画などをまとめて解決してくれます。
Portal用のコンテナ要素
document.body以外に専用のコンテナも使用できます。
// index.html
<body>
<div id="root"></div>
<div id="modal-root"></div> <!-- モーダル専用 -->
<div id="toast-root"></div> <!-- トースト専用 -->
</body>
// Modal.tsx
const modalRoot = document.getElementById('modal-root')!
function Modal({ children }: { children: React.ReactNode }) {
return createPortal(children, modalRoot)
}
まとめ
createPortalでDOM階層外にレンダリング可能- モーダル、ツールチップ、トーストなどで活用
- イベントはReactツリーに沿ってバブリングする
- アクセシビリティ(フォーカストラップ、ARIA属性)を忘れずに
- 専用のコンテナ要素を使うとDOM構造が整理される
次の章では、Zustandによる状態管理について学びます。
次に読む
状態管理・データ取得 へ進みます。Zustand による状態管理、TanStack Query によるデータフェッチング、React Router によるルーティングを学びます。