レビュー済み·難易度: 中級·更新: 2026-04-18

React で非同期処理の失敗時に Toast 通知を表示する

カスタム Toast コンポーネントと useToast フックを実装し、API エラーや非同期処理の失敗時に画面端にトースト通知を表示するパターン。

nextjserror-handlingui-component

対応バージョン

nextjs 15react 19

前提環境

React の useState・useContext の基本を理解していること

概要

React Context で Toast の状態を管理し、createPortaldocument.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-indexoverflow: 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 が存在しないため条件分岐が必要
  • addToastuseCallback でメモ化し、子コンポーネントへ渡したときの不要な再レンダリングを抑制する

注意点

react-error-retry-boundary は ErrorBoundary で再描画ツリーのエラーを捕捉してリトライ。react-suspense-boundary は React.lazy ロード失敗のフォールバック。これはエラー発生時に画面を置き換えず画面端への Toast で通知するパターンに特化。Context + Portal を使った実装を示す。

関連サンプル