メインコンテンツまでスキップ

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キーで閉じる機能を実装してみましょう。

ヒント
  1. createPortalreact-domからインポート
  2. isOpenがfalseの場合はnullを返す
  3. useEffectkeydownイベントを監視
  4. クリーンアップで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を使って確認/キャンセルの結果を返すようにしてください。

ヒント
  1. useStateで開閉状態とオプションを管理
  2. confirm関数でPromiseを返す
  3. setResolver(() => resolve)でresolve関数を保存
  4. 確認/キャンセル時に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 によるルーティングを学びます。