メインコンテンツへスキップ
見出し画像

【Next.js】削除・更新結果をトースト通知で表示する

    こんにちは、ライトです。
    これまで、管理画面で使うコンポーネントとして以下のようなものを作成してきました。

    • 管理画面レイアウト

    • StatusBadge

    • DataTable

    • Pagination

    • SearchFilterBar

    • RowActionMenu

    • ConfirmModal

    前回は、削除確認や更新確認などに使える ConfirmModal を作成しました。

    ConfirmModal は、ユーザーに対して「本当に実行してよいか」を確認するためのコンポーネントです。

    たとえば、削除操作であれば次のような流れになります。

    1. 削除するをクリック

    2. ConfirmModalを表示

    3. 削除してよいか確認

    4. 削除処理を実行

    ただ、確認して処理を実行したあとには、ユーザーに結果を伝える必要があります。

    • 削除しました

    • 削除に失敗しました

    • 更新しました

    • 更新に失敗しました

    このような操作結果の通知には、以前作成した Toast コンポーネントが使えます。

    今回は、ConfirmModal で削除や更新を確認し、処理結果を Toast で表示する仕組みを作ります。


    ConfirmModalとToastの役割

    まず、ConfirmModal と Toast の役割を整理します。

    • ConfirmModal  
      操作前の確認

    • Toast  
      操作後の結果通知

    たとえば、削除処理なら次のようになります。

    • 本当に削除しますか?  
      → ConfirmModal

    • 削除しました  
      → Toast

    このように役割を分けると、UIの意味が分かりやすくなります。


    以前作成したToastを管理画面向けに少し拡張する

    以前作成したToastは、以下のように使える形でした。

    const { showToast } = useToastContext();
    
    showToast({
      type: "success",
      message: "保存しました。",
    });

    このままでも削除や更新の通知に使えます。

    showToast({
      type: "success",
      message: "ユーザーを削除しました。",
    });

    ただ、管理画面では通知内容をもう少し分かりやすくしたいことがあります。

    たとえば、以下のように タイトル と 本文 を分けたい場合です。

    画像
    タイトル付きのトースト表示

    また、エラー通知は成功通知より少し長めに表示したいこともあります。

    そこで今回は、Toastに以下を追加します。

    • title  
      通知のタイトル

    • duration  
      表示時間


    Toastの型を更新する

    まず、Toastの型に title と duration を追加します。

    components/Toast/Toast.tsx

    import styles from "./Toast.module.css";
    
    export type ToastType = "success" | "error" | "warning" | "info";
    
    export type ToastItem = {
      id: number;
      type: ToastType;
      title?: string;
      message: string;
      duration?: number;
    };
    
    type ToastProps = {
      toast: ToastItem;
      onClose: (id: number) => void;
    };
    
    const toastLabels: Record<ToastType, string> = {
      success: "成功",
      error: "エラー",
      warning: "警告",
      info: "通知",
    };
    
    export default function Toast({ toast, onClose }: ToastProps) {
      return (
        <div className={`${styles.toast} ${styles[toast.type]}`} role="status">
          <div className={styles.content}>
            <div className={styles.label}>{toastLabels[toast.type]}</div>
    
            {toast.title && (
              <div className={styles.title}>
                {toast.title}
              </div>
            )}
    
            <div className={styles.message}>{toast.message}</div>
          </div>
    
          <button
            type="button"
            className={styles.closeButton}
            onClick={() => onClose(toast.id)}
            aria-label="通知を閉じる"
          >
            ×
          </button>
        </div>
      );
    }

    追加したのはこの部分です。

    title?: string;
    duration?: number;

    title は任意なので、これまで通り message だけでも使えます。

    showToast({
      type: "success",
      message: "保存しました。",
    });

    タイトルを付けたい場合は、次のように使います。

    showToast({
      type: "success",
      title: "削除しました",
      message: "山田 太郎を削除しました。",
    });

    Toast.module.cssを更新する

    title を表示するために、CSSも少し追加します。

    components/Toast/Toast.module.css

    .container {
      position: fixed;
      z-index: 1000;
      display: flex;
      flex-direction: column;
      gap: 12px;
      width: min(360px, calc(100vw - 32px));
      pointer-events: none;
    }
    
    .topRight {
      top: 24px;
      right: 24px;
    }
    
    .topLeft {
      top: 24px;
      left: 24px;
    }
    
    .bottomRight {
      right: 24px;
      bottom: 24px;
    }
    
    .bottomLeft {
      bottom: 24px;
      left: 24px;
    }
    
    .topCenter {
      top: 24px;
      left: 50%;
      transform: translateX(-50%);
    }
    
    .bottomCenter {
      bottom: 24px;
      left: 50%;
      transform: translateX(-50%);
    }
    
    .toast {
      display: flex;
      align-items: flex-start;
      justify-content: space-between;
      gap: 12px;
      padding: 14px 14px 14px 16px;
      border: 1px solid;
      border-radius: 14px;
      background: #ffffff;
      box-shadow: 0 16px 40px rgba(15, 23, 42, 0.14);
      animation: slideIn 0.24s cubic-bezier(0.16, 1, 0.3, 1);
      pointer-events: auto;
    }
    
    .content {
      display: flex;
      flex-direction: column;
      gap: 4px;
      min-width: 0;
    }
    
    .label {
      font-size: 11px;
      font-weight: 700;
      line-height: 1;
      opacity: 0.72;
    }
    
    .title {
      font-size: 14px;
      font-weight: 700;
      line-height: 1.4;
    }
    
    .message {
      font-size: 14px;
      line-height: 1.5;
    }
    
    .closeButton {
      width: 24px;
      height: 24px;
      flex-shrink: 0;
      border: none;
      border-radius: 999px;
      background: transparent;
      color: currentColor;
      cursor: pointer;
      font-size: 18px;
      line-height: 1;
      opacity: 0.7;
      transition:
        background 0.2s ease,
        opacity 0.2s ease;
    }
    
    .closeButton:hover {
      background: rgba(15, 23, 42, 0.08);
      opacity: 1;
    }
    
    .success {
      border-color: #86efac;
      background: #f0fdf4;
      color: #166534;
    }
    
    .error {
      border-color: #fecaca;
      background: #fef2f2;
      color: #991b1b;
    }
    
    .warning {
      border-color: #fde68a;
      background: #fffbeb;
      color: #92400e;
    }
    
    .info {
      border-color: #a5f3fc;
      background: #ecfeff;
      color: #155e75;
    }
    
    @keyframes slideIn {
      from {
        opacity: 0;
        transform: translateY(-8px) scale(0.98);
      }
    
      to {
        opacity: 1;
        transform: translateY(0) scale(1);
      }
    }

    今回追加した主なクラスは title です。

    .title {
      font-size: 14px;
      font-weight: 700;
      line-height: 1.4;
    }

    通知タイトルを少し太く表示することで、内容を読み取りやすくしています。


    ToastProviderを更新する

    次に、Context APIで作成していた ToastProvider 側も更新します。

    showToast で title と duration を受け取れるようにします。

    components/Toast/ToastProvider.tsx

    "use client";
    
    import {
      createContext,
      useCallback,
      useContext,
      useState,
    } from "react";
    import ToastContainer, { ToastPosition } from "./ToastContainer";
    import { ToastItem, ToastType } from "./Toast";
    
    type ShowToastParams = {
      type: ToastType;
      title?: string;
      message: string;
      duration?: number;
    };
    
    type ToastContextValue = {
      showToast: (params: ShowToastParams) => void;
    };
    
    type ToastProviderProps = {
      children: React.ReactNode;
      position?: ToastPosition;
    };
    
    const ToastContext = createContext<ToastContextValue | null>(null);
    
    export function ToastProvider({
      children,
      position = "top-right",
    }: ToastProviderProps) {
      const [toasts, setToasts] = useState<ToastItem[]>([]);
    
      const removeToast = useCallback((id: number) => {
        setToasts((currentToasts) =>
          currentToasts.filter((toast) => toast.id !== id)
        );
      }, []);
    
      const showToast = useCallback(
        ({ type, title, message, duration = 3000 }: ShowToastParams) => {
          const id = Date.now();
    
          const newToast: ToastItem = {
            id,
            type,
            title,
            message,
            duration,
          };
    
          setToasts((currentToasts) => [...currentToasts, newToast]);
    
          window.setTimeout(() => {
            removeToast(id);
          }, duration);
        },
        [removeToast]
      );
    
      return (
        <ToastContext.Provider value={{ showToast }}>
          {children}
    
          <ToastContainer
            toasts={toasts}
            position={position}
            onClose={removeToast}
          />
        </ToastContext.Provider>
      );
    }
    
    export function useToastContext() {
      const context = useContext(ToastContext);
    
      if (!context) {
        throw new Error("useToastContext must be used within ToastProvider");
      }
    
      return context;
    }

    ShowToastParams に title と duration を追加しています。

    type ShowToastParams = {
      type: ToastType;
      title?: string;
      message: string;
      duration?: number;
    };

    duration は指定しない場合、3秒にしています。

    duration = 3000

    成功通知は短め、エラー通知は長めにするなど、通知ごとに調整できます。

    showToast({
      type: "error",
      title: "削除に失敗しました",
      message: "時間をおいて再度お試しください。",
      duration: 5000,
    });

    layout.tsxでToastProviderを設定する

    ToastProvider は、アプリ全体で使えるように app/layout.tsx に設定しておきます。

    app/layout.tsx

    import { ToastProvider } from "@/components/Toast";
    import type { Metadata } from "next";
    import { Noto_Sans_JP } from "next/font/google";
    import "./globals.css";
    
    const notoSansJP = Noto_Sans_JP({
      subsets: ["latin"],
      weight: ["400", "500", "600", "700"],
      display: "swap",
    });
    
    export const metadata: Metadata = {
      title: "Admin Dashboard",
      description: "管理画面ダッシュボード",
    };
    
    export default function RootLayout({
      children,
    }: {
      children: React.ReactNode;
    }) {
      return (
        <html lang="ja">
          <body className={`${notoSansJP.className} antialiased`}>
            <ToastProvider position="top-right">
              {children}
            </ToastProvider>
          </body>
        </html>
      );
    }

    これで、Provider配下のどのコンポーネントからでも showToast() を呼び出せます。

    const { showToast } = useToastContext();

    削除結果をToastで表示する

    次に、削除処理の結果をToastで表示します。

    前回作成した ConfirmModal と RowActionMenu を組み合わせたユーザー一覧を例にします。

    const { showToast } = useToastContext();

    削除処理では、成功時と失敗時でToastを出し分けます。

    const handleDelete = async () => {
      if (!selectedUser) {
        return;
      }
    
      setIsDeleting(true);
    
      try {
        await new Promise((resolve) => setTimeout(resolve, 800));
    
        setUsers((currentUsers) =>
          currentUsers.filter((user) => user.id !== selectedUser.id)
        );
    
        showToast({
          type: "success",
          title: "削除しました",
          message: `${selectedUser.name}を削除しました。`,
        });
    
        setIsDeleteModalOpen(false);
        setSelectedUser(null);
      } catch {
        showToast({
          type: "error",
          title: "削除に失敗しました",
          message: "時間をおいて再度お試しください。",
          duration: 5000,
        });
      } finally {
        setIsDeleting(false);
      }
    };

    成功した場合は、成功通知を表示します。

    showToast({
      type: "success",
      title: "削除しました",
      message: `${selectedUser.name}を削除しました。`,
    });

    失敗した場合は、エラー通知を表示します。

    showToast({
      type: "error",
      title: "削除に失敗しました",
      message: "時間をおいて再度お試しください。",
      duration: 5000,
    });

    エラー通知は、内容を読んでもらいたいので少し長めに表示しています。


    更新結果をToastで表示する

    更新確認でも同じように使えます。

    const handleUpdate = async () => {
      setIsSaving(true);
    
      try {
        await new Promise((resolve) => setTimeout(resolve, 800));
    
        showToast({
          type: "success",
          title: "更新しました",
          message: "変更内容を保存しました。",
        });
    
        setIsUpdateModalOpen(false);
      } catch {
        showToast({
          type: "error",
          title: "更新に失敗しました",
          message: "入力内容を確認して、再度お試しください。",
          duration: 5000,
        });
      } finally {
        setIsSaving(false);
      }
    };

    成功時は success、失敗時は error を使います。

    showToast({
      type: "success",
      title: "更新しました",
      message: "変更内容を保存しました。",
    });
    showToast({
      type: "error",
      title: "更新に失敗しました",
      message: "入力内容を確認して、再度お試しください。",
      duration: 5000,
    });

    ConfirmModal は削除確認だけでなく、更新確認にも使えるので、Toastと組み合わせると使い回しやすくなります。


    ConfirmModalと組み合わせる

    削除確認の ConfirmModal は、次のように使います。

    <ConfirmModal
      isOpen={isDeleteModalOpen}
      title="ユーザーを削除しますか?"
      description="この操作は取り消せません。削除すると、ユーザー情報は一覧から削除されます。"
      confirmLabel="削除する"
      cancelLabel="キャンセル"
      variant="danger"
      isLoading={isDeleting}
      onConfirm={handleDelete}
      onClose={closeDeleteModal}
    >
      {selectedUser && (
        <div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-700">
          <div className="font-medium text-slate-900">
            {selectedUser.name}
          </div>
          <div className="mt-1 text-slate-500">
            {selectedUser.email}
          </div>
        </div>
      )}
    </ConfirmModal>

    この流れになります。

    1. RowActionMenuで削除を選択

    2. ConfirmModalを開く

    3. 削除するをクリック

    4. handleDeleteを実行

    5. 成功・失敗に応じてToastを表示

    ConfirmModal は操作前の確認、Toast は操作後の結果通知です。


    RowActionMenuから削除確認を開く

    RowActionMenu 側では、削除をクリックしたときに対象ユーザーをセットし、確認モーダルを開きます。

    <RowActionMenu
      label={`${user.name}の操作`}
      actions={[
        {
          label: "詳細を見る",
          onClick: () => {
            console.log("詳細:", user.id);
          },
        },
        {
          label: "編集する",
          onClick: () => {
            console.log("編集:", user.id);
          },
        },
        {
          label: "削除する",
          variant: "danger",
          onClick: () => {
            setSelectedUser(user);
            setIsDeleteModalOpen(true);
          },
        },
      ]}
    />

    この時点では、まだ削除処理は実行しません。

    削除処理は、ConfirmModal の実行ボタンを押したときに行います。

    onConfirm={handleDelete}

    これにより、誤操作を防ぎやすくなります。


    全体の流れ

    ここまでをまとめると、全体の流れは次のようになります。

    1. DataTable

    2. RowActionMenu

    3. ConfirmModal

    4. 削除・更新処理

    5. Toast通知

    それぞれの役割は以下です。

    • DataTable  
      一覧を表示する

    • RowActionMenu  
      行ごとの操作を表示する

    • ConfirmModal  
      操作前に確認する

    • Toast  
      操作後の結果を通知する

    このように役割を分けると、各コンポーネントの責務が分かりやすくなります。


    実装のポイント

    今回のポイントは、既存のToastを管理画面向けに少し拡張したことです。

    • title  
      通知の見出し

    • message  
      通知の本文

    • duration  
      表示時間

    これにより、操作結果をより分かりやすく伝えられます。

    showToast({
      type: "success",
      title: "削除しました",
      message: "山田 太郎を削除しました。",
    });

    また、ConfirmModalとの役割分担も重要です。

    • ConfirmModal
      実行前の確認

    • Toast  
      実行後の結果通知

    削除や更新のような処理では、この2つを組み合わせると、ユーザーにとって分かりやすい操作フローになります。


    まとめ

    今回は、削除・更新結果をトースト通知で表示する仕組みを作成しました。

    以前作成したToastコンポーネントをベースに、管理画面向けに以下を追加しました。

    • title

    • duration

    これにより、通知の見出しと本文を分けたり、通知ごとに表示時間を変えたりできるようになりました。

    また、削除や更新の流れは次のように整理しました。

    • RowActionMenu  
      操作を選ぶ

    • ConfirmModal  
      実行前に確認する

    • Toast  
      実行後の結果を表示する

    管理画面では、削除、更新、公開、停止など、確認が必要な操作が多くあります。

    ConfirmModal と Toast を組み合わせることで、操作前の確認と操作後の通知を分けて扱えるようになります。

    これで、管理画面の操作フローがかなり実務に近づいてきました。

    #NextJS #React #コンポーネント #通知 #トースト #メッセージ

     
     
    主にバックエンドの開発をしています。 AIを業務に使いながら、「もっと早く知りたかった」と思うことが山ほど出てきました。 そのリアルな気づきや失敗も含めて、エンジニアに届けたくてnoteを始めました。 現場目線の発信を目指します。 https://code-light.jp/

    あなたへのおすすめ