【Next.js】フラッシュメッセージを実装する|簡単なToDoアプリの作成

Next.jsでユーザーの操作結果を知らせるフラッシュメッセージ機能の実装方法を学びます。ReactのContext APIを使い、アプリのどこからでも呼び出せるメッセージ表示の仕組みを構築します。ToDoの追加や削除が成功したか失敗したかを、APIからの応答に応じて画面上部に通知する具体的な手順を、簡単なToDoアプリの開発を通して丁寧に解説します。

作成日: 更新日:

開発環境

  • OS: Windows10
  • Visual Studio Code: 1.73.0
  • node: 22.14.0
  • react: 19.0.0
  • next: 15.3.2
  • tailwindcss: 4
  • Prisma: 6.8.2
  • NextAuth: 5.8.0
  • @auth/prisma-adapter: 2.9.1
  • @prisma/client: 6.8.2
  • bcrypt: 6.0.0
  • @types/bcrypt: 5.0.2

サンプルコード

/src/app/api/todos/route.tsx

/src/app/api/todos/route.tsx
1       where: { userId: Number(user.id) },
2       orderBy: { id: 'desc' },
3     })
4-    return NextResponse.json(todos)
5+    return NextResponse.json(
6+      {
7+        status: 'success',
8+        message: 'Todoの取得に成功しました。',
9+        todos: todos,
10+      },
11+      { status: 200 }
12+    )
13   } catch (error) {
14-    return NextResponse.json({ message: 'Unauthorized' }, { status: 401 })
15+    return NextResponse.json(
16+      {
17+        status: 'error',
18+        message: 'Todoの取得に失敗しました。',
19+      },
20+      { status: 500 }
21+    )
22   }
23 }
24 
25     const newTodo = await prisma.todo.create({
26       data: { text, userId: Number(user.id) },
27     })
28-    return NextResponse.json(newTodo)
29+    return NextResponse.json(
30+      {
31+        status: 'success',
32+        message: 'Todoの追加に成功しました。',
33+        todo: newTodo,
34+      },
35+      { status: 201 }
36+    )
37   } catch (error) {
38-    return NextResponse.json({ message: 'Unauthorized' }, { status: 401 })
39+    return NextResponse.json(
40+      {
41+        status: 'error',
42+        message: 'Todoの追加に失敗しました。',
43+      },
44+      { status: 500 }
45+    )
46   }
47 }
48 
49 
50     const todo = await prisma.todo.findUnique({ where: { id } })
51     if (!todo || todo.userId !== Number(user.id)) {
52-      return NextResponse.json({ message: 'Forbidden' }, { status: 403 })
53+      return NextResponse.json(
54+        {
55+          status: 'error',
56+          message: 'Todoの削除権限がありません。',
57+        },
58+        { status: 403 }
59+      )
60     }
61 
62     await prisma.todo.delete({ where: { id } })
63-    return NextResponse.json({ message: 'Deleted' })
64+    return NextResponse.json(
65+      {
66+        status: 'success',
67+        message: 'Todoの削除に成功しました。',
68+      },
69+      { status: 200 }
70+    )
71   } catch (error) {
72-    return NextResponse.json({ message: 'Unauthorized' }, { status: 401 })
73+    return NextResponse.json(
74+      {
75+        status: 'error',
76+        message: 'Todoの削除に失敗しました。',
77+      },
78+      { status: 500 }
79+    )
80   }
81 }
82 
83 
84     const todo = await prisma.todo.findUnique({ where: { id } })
85     if (!todo || todo.userId !== Number(user.id)) {
86-      return NextResponse.json({ message: 'Forbidden' }, { status: 403 })
87+      return NextResponse.json(
88+        {
89+          status: 'error',
90+          message: 'Todoの更新権限がありません。',
91+        },
92+        { status: 403 }
93+      )
94     }
95 
96-    await prisma.todo.update({
97+    const updated = await prisma.todo.update({
98       where: { id },
99       data: { text },
100     })
101-    return NextResponse.json({ message: 'Updated' })
102+    return NextResponse.json(
103+      {
104+        status: 'success',
105+        message: 'Todoの更新に成功しました。',
106+        todo: updated,
107+      },
108+      { status: 200 }
109+    )
110   } catch (error) {
111-    return NextResponse.json({ message: 'Unauthorized' }, { status: 401 })
112+    return NextResponse.json(
113+      {
114+        status: 'error',
115+        message: 'Todoの更新に失敗しました。',
116+      },
117+      { status: 500 }
118+    )
119   }
120 }
121

/src/app/components/FlashMessage.tsx

/src/app/components/FlashMessage.tsx
1+"use client"
2+
3+import { useFlashMessage } from "@/app/context/FlashMessageContext"
4+import { FlashType } from "@/app/types/flash"
5+
6+export default function FlashMessage() {
7+  const { message, type, visible } = useFlashMessage();
8+
9+  if (!visible) return null;
10+
11+  const typeClass: Record<FlashType, string> = {
12+    success: "bg-green-100 border-green-400 text-green-700",
13+    error: "bg-red-100 border-red-400 text-red-700",
14+  };
15+
16+  return (
17+    <div
18+      className={`${typeClass[type]} px-4 py-3 rounded text-center`}
19+      role="alert"
20+    >
21+      {message}
22+    </div>
23+  );
24+}
25

/src/app/context/FlashMessageContext.tsx

/src/app/context/FlashMessageContext.tsx
1+"use client"
2+
3+import { createContext, useContext, useState } from "react"
4+import { FlashType, FlashMessageContextType } from "@/app/types/flash"
5+
6+const FlashMessageContext = createContext<FlashMessageContextType | undefined>(
7+  undefined
8+)
9+
10+export const FlashMessageProvider = ({ children }: { children: React.ReactNode }) => {
11+  const [message, setMessage] = useState("")
12+  const [type, setType] = useState<FlashType>("success")
13+  const [visible, setVisible] = useState(false)
14+
15+  const showMessage = (message: string, type: FlashType) => {
16+    setMessage(message)
17+    setType(type)
18+    setVisible(true)
19+    setTimeout(() => setVisible(false), 3000)
20+  }
21+
22+  const hideMessage = () => setVisible(false)
23+
24+  return (
25+    <FlashMessageContext.Provider value={{ message, type, visible, showMessage, hideMessage }}>
26+      {children}
27+    </FlashMessageContext.Provider>
28+  )
29+}
30+
31+export const useFlashMessage = () => {
32+  const context = useContext(FlashMessageContext)
33+  if (!context) throw new Error("useFlashMessage must be used within FlashMessageProvider")
34+  return context
35+}
36

/src/app/layout.tsx

/src/app/layout.tsx
1 import { SessionWrapper } from "./components/SessionWrapper";
2 import Header from '@/app/components/Header'
3 import Footer from '@/app/components/Footer'
4+import FlashMessage from '@/app/components/FlashMessage'
5+import { FlashMessageProvider } from "@/app/context/FlashMessageContext"
6+
7 
8 const geistSans = Geist({
9   variable: "--font-geist-sans",
10       <body
11         className={`${geistSans.variable} ${geistMono.variable} antialiased`}
12       >
13-        <SessionWrapper>
14-          <Header />
15-          {children}
16-          <Footer />
17-        </SessionWrapper>
18+        <FlashMessageProvider>
19+          <SessionWrapper>
20+            <Header />
21+            <FlashMessage />
22+            {children}
23+            <Footer />
24+          </SessionWrapper>
25+        </FlashMessageProvider>
26       </body>
27     </html>
28-  );
29+  )
30 }
31

/src/app/page.tsx

/src/app/page.tsx
1 'use client';
2 
3 import { useEffect, useState } from 'react';
4+import { useFlashMessage } from '@/app/context/FlashMessageContext';
5 
6 type Todo = {
7   id: number;
8   const [input, setInput] = useState('');
9   const [editId, setEditId] = useState<number | null>(null);
10   const [editText, setEditText] = useState('');
11+  const { showMessage } = useFlashMessage();
12 
13   const fetchTodos = async () => {
14     const res = await fetch('/api/todos');
15     const data = await res.json();
16-    setTodos(data);
17+
18+    if (data.status === 'success') {
19+      setTodos(data.todos);
20+    } else {
21+      showMessage(data.message, 'error');
22+    }
23   };
24 
25   const handleAdd = async () => {
26     if (!input.trim()) return;
27-    await fetch('/api/todos', {
28+
29+    const res = await fetch('/api/todos', {
30       method: 'POST',
31       headers: { 'Content-Type': 'application/json' },
32       body: JSON.stringify({ text: input }),
33     });
34-    setInput('');
35-    fetchTodos();
36+
37+    const data = await res.json();
38+    if (data.status === 'success') {
39+      showMessage(data.message, 'success');
40+      setInput('');
41+      fetchTodos();
42+    } else {
43+      showMessage(data.message, 'error');
44+    }
45   };
46 
47   const handleDelete = async (id: number) => {
48-    await fetch('/api/todos', {
49+    const res = await fetch('/api/todos', {
50       method: 'DELETE',
51       headers: { 'Content-Type': 'application/json' },
52       body: JSON.stringify({ id }),
53     });
54-    fetchTodos();
55+
56+    const data = await res.json();
57+    if (data.status === 'success') {
58+      showMessage(data.message, 'success');
59+      fetchTodos();
60+    } else {
61+      showMessage(data.message, 'error');
62+    }
63   };
64 
65   const handleEdit = (todo: Todo) => {
66 
67   const handleUpdate = async () => {
68     if (editId === null || !editText.trim()) return;
69-    await fetch('/api/todos', {
70+
71+    const res = await fetch('/api/todos', {
72       method: 'PATCH',
73       headers: { 'Content-Type': 'application/json' },
74       body: JSON.stringify({ id: editId, text: editText }),
75     });
76-    setEditId(null);
77-    setEditText('');
78-    fetchTodos();
79+
80+    const data = await res.json();
81+    if (data.status === 'success') {
82+      showMessage(data.message, 'success');
83+      setEditId(null);
84+      setEditText('');
85+      fetchTodos();
86+    } else {
87+      showMessage(data.message, 'error');
88+    }
89   };
90 
91   useEffect(() => {
92-    fetchTodos()
93-  }, [])
94+    fetchTodos();
95+  }, []);
96 
97   return (
98     <main className="min-h-screen bg-gray-100 p-6">
99

/src/app/types/flash.ts

/src/app/types/flash.ts
1+export type FlashType = "success" | "error"
2+
3+export type FlashMessageContextType = {
4+  message: string
5+  type: FlashType
6+  visible: boolean
7+  showMessage: (message: string, type: FlashType) => void
8+  hideMessage: () => void
9+}
10

コード解説

変更点: フラッシュメッセージ用の型定義を追加

/src/app/types/flash.ts
1+export type FlashType = "success" | "error"
2+
3+export type FlashMessageContextType = {
4+  message: string
5+  type: FlashType
6+  visible: boolean
7+  showMessage: (message: string, type: FlashType) => void
8+  hideMessage: () => void
9+}

ここでは、TypeScriptを使ってフラッシュメッセージに関連するデータの「型」を定義しています。型を定義することで、コード内で扱うデータの構造が明確になり、予期せぬエラーを防ぐことができます。

  • FlashTypeは、メッセージの種類を表す型です。"success"(成功)または"error"(エラー)のどちらかの文字列しか許容しないように設定しています。
  • FlashMessageContextTypeは、後述する「Context」という仕組みで共有されるデータの型を定義しています。メッセージの文字列(message)、種類(type)、表示状態(visible)、そしてメッセージを表示・非表示にするための関数(showMessage, hideMessage)が含まれることを示しています。

変更点: APIが返すデータの形式を統一

/src/app/api/todos/route.tsx
1       const newTodo = await prisma.todo.create({
2         data: { text, userId: Number(user.id) },
3       })
4-      return NextResponse.json(newTodo)
5+      return NextResponse.json(
6+        {
7+          status: 'success',
8+          message: 'Todoの追加に成功しました。',
9+          todo: newTodo,
10+        },
11+        { status: 201 }
12+      )
13     } catch (error) {
14-      return NextResponse.json({ message: 'Unauthorized' }, { status: 401 })
15+      return NextResponse.json(
16+        {
17+          status: 'error',
18+          message: 'Todoの追加に失敗しました。',
19+        },
20+        { status: 500 }
21+      )
22     }

サーバー側(API)がフロントエンド(画面側)に応答として返すデータの形式を変更しました。 以前は処理が成功したときと失敗したときで、返されるデータの形式が異なっていました。今回の変更で、成功・失敗にかかわらず、必ずstatus(処理結果)とmessage(ユーザーに表示するメッセージ)を含む、一貫した形式のJSONオブジェクトを返すように統一しました。 これにより、フロントエンド側でAPIからの応答を簡単に扱うことができ、その結果に応じたフラッシュメッセージの表示処理をシンプルに記述できるようになります。この変更は、ToDoの取得、追加、更新、削除すべてのAPI処理に適用されています。

変更点: アプリ全体でメッセージ情報を共有する仕組み(Context)を作成

/src/app/context/FlashMessageContext.tsx
1+"use client"
2+
3+import { createContext, useContext, useState } from "react"
4+import { FlashType, FlashMessageContextType } from "@/app/types/flash"
5+
6+const FlashMessageContext = createContext<FlashMessageContextType | undefined>(
7+  undefined
8+)
9+
10+export const FlashMessageProvider = ({ children }: { children: React.ReactNode }) => {
11+  const [message, setMessage] = useState("")
12+  const [type, setType] = useState<FlashType>("success")
13+  const [visible, setVisible] = useState(false)
14+
15+  const showMessage = (message: string, type: FlashType) => {
16+    setMessage(message)
17+    setType(type)
18+    setVisible(true)
19+    setTimeout(() => setVisible(false), 3000)
20+  }
21+
22+  const hideMessage = () => setVisible(false)
23+
24+  return (
25+    <FlashMessageContext.Provider value={{ message, type, visible, showMessage, hideMessage }}>
26+      {children}
27+    </FlashMessageContext.Provider>
28+  )
29+}
30+
31+export const useFlashMessage = () => {
32+  const context = useContext(FlashMessageContext)
33+  if (!context) throw new Error("useFlashMessage must be used within FlashMessageProvider")
34+  return context
35+}

ReactのContext APIを利用して、フラッシュメッセージの状態(メッセージ内容、種類、表示/非表示)とそれを操作する関数を、アプリケーション内のどのコンポーネントからでも呼び出せるようにする仕組みを構築しました。

  • useStateを使って、メッセージの状態を管理します。
  • showMessage関数が呼び出されると、指定されたメッセージと種類(成功/エラー)で状態を更新し、メッセージを画面に表示(setVisible(true))します。同時にsetTimeoutを使い、3秒後に自動的にメッセージが消える(setVisible(false))ように設定しています。
  • FlashMessageProviderは、これらの状態や関数を子コンポーネントに提供する役割を持つコンポーネントです。
  • useFlashMessageは「カスタムフック」と呼ばれるもので、これを使うことで他のコンポーネントから簡単にContextの値にアクセスできるようになります。

変更点: 画面にメッセージを表示するためのUIコンポーネントを作成

/src/app/components/FlashMessage.tsx
1+"use client"
2+
3+import { useFlashMessage } from "@/app/context/FlashMessageContext"
4+import { FlashType } from "@/app/types/flash"
5+
6+export default function FlashMessage() {
7+  const { message, type, visible } = useFlashMessage();
8+
9+  if (!visible) return null;
10+
11+  const typeClass: Record<FlashType, string> = {
12+    success: "bg-green-100 border-green-400 text-green-700",
13+    error: "bg-red-100 border-red-400 text-red-700",
14+  };
15+
16+  return (
17+    <div
18+      className={`${typeClass[type]} px-4 py-3 rounded text-center`}
19+      role="alert"
20+    >
21+      {message}
22+    </div>
23+  );
24+}

これは、実際にブラウザの画面上に表示されるフラッシュメッセージの見た目と振る舞いを定義するReactコンポーネントです。"use client"は、このコンポーネントがユーザーのブラウザ上で動作することをNext.jsに指示するおまじないです。

  • 先ほど作成したuseFlashMessageフックを使い、Contextから現在のメッセージの状態(message, type, visible)を取得します。
  • if (!visible) return null; の行で、visiblefalse(非表示)の場合は何も画面に描画しないようにしています。
  • visibletrueの場合、メッセージを表示します。その際、typeの値に応じてCSSクラスを切り替え、成功(success)なら緑色、エラー(error)なら赤色の背景や文字色を適用しています。

変更点: アプリケーションの全ページでフラッシュメッセージを使えるように設定

/src/app/layout.tsx
1 import { SessionWrapper } from "./components/SessionWrapper";
2 import Header from '@/app/components/Header'
3 import Footer from '@/app/components/Footer'
4+import FlashMessage from '@/app/components/FlashMessage'
5+import { FlashMessageProvider } from "@/app/context/FlashMessageContext"
6 
7-        <SessionWrapper>
8-          <Header />
9-          {children}
10-          <Footer />
11-        </SessionWrapper>
12+        <FlashMessageProvider>
13+          <SessionWrapper>
14+            <Header />
15+            <FlashMessage />
16+            {children}
17+            <Footer />
18+          </SessionWrapper>
19+        </FlashMessageProvider>

アプリケーション全体の基本的なレイアウトを定義するlayout.tsxファイルを変更し、作成したフラッシュメッセージの仕組みを組み込んでいます。

  • <FlashMessageProvider>でアプリケーション全体を囲むことで、どのページコンポーネントからでもフラッシュメッセージのContextにアクセスできるようになります。
  • <FlashMessage />コンポーネントを<Header />の下に配置しました。これにより、フラッシュメッセージが表示されるときは、常にページのヘッダーのすぐ下に表示されるようになります。

変更点: ToDoの追加・削除などの操作結果をメッセージで通知

/src/app/page.tsx
1 'use client';
2 
3 import { useEffect, useState } from 'react';
4+import { useFlashMessage } from '@/app/context/FlashMessageContext';
5 
6-  // ...
7+  const { showMessage } = useFlashMessage();
8 
9   const handleAdd = async () => {
10     if (!input.trim()) return;
11-    await fetch('/api/todos', {
12+    const res = await fetch('/api/todos', {
13       method: 'POST',
14       headers: { 'Content-Type': 'application/json' },
15       body: JSON.stringify({ text: input }),
16     });
17-    setInput('');
18-    fetchTodos();
19+    const data = await res.json();
20+    if (data.status === 'success') {
21+      showMessage(data.message, 'success');
22+      setInput('');
23+      fetchTodos();
24+    } else {
25+      showMessage(data.message, 'error');
26+    }
27   };

最後に、ToDoアプリのメインページで、ユーザーの操作に応じてフラッシュメッセージを実際に表示する処理を追加しました。

  • まず、useFlashMessageフックを使って、メッセージを表示するためのshowMessage関数を取得します。
  • ToDoを追加するhandleAdd関数などのAPIを呼び出す処理の中で、サーバーからの応答(data)を受け取ります。
  • 応答データに含まれるdata.statusの値を確認し、それが'success'であれば、成功メッセージをshowMessage関数に渡して表示します。エラーであれば、エラーメッセージを表示します。
  • 成功した場合にのみ、入力欄を空にしたり、ToDoリストを再読み込みしたりする後続の処理を実行します。この変更は、ToDoの取得、追加、更新、削除といったすべての操作に適用されています。

おわりに

今回は、Next.jsでユーザーの操作結果を通知するフラッシュメッセージ機能を実装しました。ReactのContext APIを利用することで、アプリケーションのどのページからでも簡単に呼び出せるメッセージ管理の仕組みを構築しました。また、APIが返すデータの形式を統一し、その結果に応じて成功やエラーのメッセージを動的に表示する一連の流れを体験できました。この仕組みはユーザー体験を向上させる上で非常に重要ですので、ぜひご自身のアプリケーション開発にも活用してみてください。

関連コンテンツ