概要
React Context で Toast の状態を管理し、createPortal で document.body 直下にレンダリングすることで、どのコンポーネントからでもトースト通知を表示できる。API エラーや非同期処理の失敗時に画面を遷移させずに通知するパターン。
インストール
# 追加インストールは不要
実装
Toast の型と Context
// lib/toast/context.tsx
"use client";
import { createContext, useCallback, useContext, useRef, useState } from "react";
import { createPortal } from "react-dom";
type ToastType = "success" | "error" | "info";
type Toast = {
id: string;
message: string;
type: ToastType;
};
type ToastContextValue = {
addToast: (message: string, type?: ToastType) => void;
};
const ToastContext = createContext<ToastContextValue | null>(null);
const TOAST_DURATION_MS = 4000;
const TYPE_STYLES: Record<ToastType, string> = {
success: "bg-green-600 text-white",
error: "bg-red-600 text-white",
info: "bg-gray-800 text-white",
};
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const timers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
timers.current.delete(id);
}, []);
const addToast = useCallback(
(message: string, type: ToastType = "info") => {
const id = crypto.randomUUID();
setToasts((prev) => [...prev, { id, message, type }]);
const timer = setTimeout(() => removeToast(id), TOAST_DURATION_MS);
timers.current.set(id, timer);
},
[removeToast],
);
return (
<ToastContext.Provider value={{ addToast }}>
{children}
{typeof document !== "undefined" &&
createPortal(
<div
aria-live="polite"
className="pointer-events-none fixed bottom-6 right-6 z-50 flex flex-col gap-2"
>
{toasts.map((toast) => (
<div
key={toast.id}
role="alert"
className={`pointer-events-auto flex items-center gap-3 rounded-lg px-4 py-3 text-sm shadow-lg ${TYPE_STYLES[toast.type]}`}
>
<span className="flex-1">{toast.message}</span>
<button
onClick={() => {
clearTimeout(timers.current.get(toast.id));
removeToast(toast.id);
}}
className="ml-2 opacity-70 hover:opacity-100"
aria-label="閉じる"
>
✕
</button>
</div>
))}
</div>,
document.body,
)}
</ToastContext.Provider>
);
}
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error("useToast must be used within ToastProvider");
return ctx;
}
ルートレイアウトへの組み込み
// app/layout.tsx
import { ToastProvider } from "@/lib/toast/context";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ja">
<body>
<ToastProvider>{children}</ToastProvider>
</body>
</html>
);
}
使用例(API エラー時に Toast 表示)
// components/DeleteButton.tsx
"use client";
import { useState } from "react";
import { useToast } from "@/lib/toast/context";
type Props = {
itemId: number;
onDeleted: () => void;
};
export default function DeleteButton({ itemId, onDeleted }: Props) {
const { addToast } = useToast();
const [loading, setLoading] = useState(false);
async function handleDelete() {
setLoading(true);
try {
const res = await fetch(`/api/items/${itemId}`, { method: "DELETE" });
if (!res.ok) {
const data = await res.json() as { error?: string };
addToast(data.error ?? "削除に失敗しました", "error");
return;
}
addToast("削除しました", "success");
onDeleted();
} catch {
addToast("通信エラーが発生しました", "error");
} finally {
setLoading(false);
}
}
return (
<button
onClick={handleDelete}
disabled={loading}
className="rounded bg-red-600 px-3 py-1.5 text-sm text-white hover:bg-red-700 disabled:opacity-50"
>
{loading ? "削除中…" : "削除"}
</button>
);
}
Toast を手動で確認するページ
// app/toast-demo/page.tsx
"use client";
import { useToast } from "@/lib/toast/context";
export default function ToastDemoPage() {
const { addToast } = useToast();
return (
<main className="mx-auto max-w-md p-8">
<h1 className="mb-6 text-2xl font-bold">Toast デモ</h1>
<div className="flex flex-col gap-3">
<button
onClick={() => addToast("処理が成功しました", "success")}
className="rounded bg-green-600 px-4 py-2 text-sm text-white hover:bg-green-700"
>
成功 Toast
</button>
<button
onClick={() => addToast("エラーが発生しました", "error")}
className="rounded bg-red-600 px-4 py-2 text-sm text-white hover:bg-red-700"
>
エラー Toast
</button>
<button
onClick={() => addToast("お知らせがあります", "info")}
className="rounded bg-gray-700 px-4 py-2 text-sm text-white hover:bg-gray-800"
>
情報 Toast
</button>
</div>
</main>
);
}
ポイント
createPortal(jsx, document.body)でコンポーネントツリーの外側(body直下)にレンダリングする。z-indexやoverflow: hiddenの親要素に影響されず、常に最前面に表示されるaria-live="polite"で Toast 領域をスクリーンリーダーに通知エリアとして登録する。各 Toast にrole="alert"を付けることで読み上げが即座にトリガーされるuseRef<Map<string, ReturnType<typeof setTimeout>>>でタイマー ID を管理する。閉じるボタンで手動削除するときにclearTimeoutしてタイマーをキャンセルし、既に削除済みのトーストへの setState を防ぐtypeof document !== "undefined"のガードは Server Component でのビルドエラーを防ぐ。ToastProviderは"use client"を付けているが、createPortalは SSR 時にdocumentが存在しないため条件分岐が必要addToastはuseCallbackでメモ化し、子コンポーネントへ渡したときの不要な再レンダリングを抑制する