【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.tsx1 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.tsx1+"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.tsx1+"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.tsx1 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.tsx1 '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.ts1+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.ts1+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.tsx1 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.tsx1+"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.tsx1+"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;の行で、visibleがfalse(非表示)の場合は何も画面に描画しないようにしています。visibleがtrueの場合、メッセージを表示します。その際、typeの値に応じてCSSクラスを切り替え、成功(success)なら緑色、エラー(error)なら赤色の背景や文字色を適用しています。
変更点: アプリケーションの全ページでフラッシュメッセージを使えるように設定
/src/app/layout.tsx1 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.tsx1 '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が返すデータの形式を統一し、その結果に応じて成功やエラーのメッセージを動的に表示する一連の流れを体験できました。この仕組みはユーザー体験を向上させる上で非常に重要ですので、ぜひご自身のアプリケーション開発にも活用してみてください。