【Next.js】処理中にローディングスピナーを表示する|簡単なToDoアプリの作成
Next.jsで、フォーム送信などの時間のかかる処理中にローディングスピナーを表示する方法を解説します。useStateでローディング状態を管理し、その状態に応じてボタンを無効化したり、表示を切り替えたりする具体的な実装が学べます。ユーザー体験を向上させる実践的なテクニックです。
開発環境
- 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
- zod: 3.25.56
- nodemailer: 6.10.1
- @types/nodemailer: 6.4.17
サンプルコード
/src/app/components/LoadingSpinner.tsx
/src/app/components/LoadingSpinner.tsx1+import React from 'react'; 2+ 3+export default function LoadingSpinner({ text = '読み込み中...' }: { text?: string }) { 4+ return ( 5+ <div className="flex items-center justify-center gap-2"> 6+ <svg 7+ className="animate-spin h-5 w-5 text-white" 8+ xmlns="http://www.w3.org/2000/svg" 9+ fill="none" 10+ viewBox="0 0 24 24" 11+ > 12+ <circle 13+ className="opacity-25" 14+ cx="12" 15+ cy="12" 16+ r="10" 17+ stroke="currentColor" 18+ strokeWidth="4" 19+ ></circle> 20+ <path 21+ className="opacity-75" 22+ fill="currentColor" 23+ d="M4 12a8 8 0 018-8v8H4z" 24+ ></path> 25+ </svg> 26+ <span>{text}</span> 27+ </div> 28+ ); 29+} 30
/src/app/contact/page.tsx
/src/app/contact/page.tsx1 import { useState } from 'react'; 2 import { useFlashMessage } from '@/app/context/FlashMessageContext'; 3 import { z } from 'zod'; 4+import LoadingSpinner from '@/app/components/LoadingSpinner'; 5 6 const schema = z.object({ 7 name: z.string().min(2, { message: '名前は2文字以上で入力してください' }), 8 const [form, setForm] = useState({ name: '', email: '', message: '' }); 9 const [errors, setErrors] = useState<{ name?: string; email?: string; message?: string }>({}); 10 const { showMessage } = useFlashMessage(); 11+ const [loading, setLoading] = useState(false) 12 13 const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { 14 setForm({ ...form, [e.target.name]: e.target.value }); 15 const handleSubmit = async (e: React.FormEvent) => { 16 e.preventDefault(); 17 setErrors({}); 18+ setLoading(true); 19 20 const result = schema.safeParse(form); 21 if (!result.success) { 22 if (path[0] === 'message') fieldErrors.message = message; 23 }); 24 setErrors(fieldErrors); 25+ setLoading(false); 26 return; 27 } 28 29 } 30 } catch (err) { 31 showMessage('エラーが発生しました。もう一度お試しください。', 'error'); 32+ } finally { 33+ setLoading(false); 34 } 35 }; 36 37 38 <button 39 type="submit" 40- className="w-full bg-blue-500 text-white py-2 px-4 rounded hover:bg-blue-600 transition" 41+ className={`w-full text-white py-2 px-4 rounded transition ${loading ? 'bg-blue-300 cursor-not-allowed' : 'bg-blue-500 hover:bg-blue-600'}`} 42+ disabled={loading} 43 > 44- 送信 45+ {loading ? <LoadingSpinner text="送信中..." /> : '送信'} 46 </button> 47 </form> 48 </div> 49
コード解説
変更点: ローディングスピナーコンポーネントの作成
/src/app/components/LoadingSpinner.tsx1+import React from 'react'; 2+ 3+export default function LoadingSpinner({ text = '読み込み中...' }: { text?: string }) { 4+ return ( 5+ <div className="flex items-center justify-center gap-2"> 6+ <svg 7+ className="animate-spin h-5 w-5 text-white" 8+ xmlns="http://www.w3.org/2000/svg" 9+ fill="none" 10+ viewBox="0 0 24 24" 11+ > 12+ <circle 13+ className="opacity-25" 14+ cx="12" 15+ cy="12" 16+ r="10" 17+ stroke="currentColor" 18+ strokeWidth="4" 19+ ></circle> 20+ <path 21+ className="opacity-75" 22+ fill="currentColor" 23+ d="M4 12a8 8 0 018-8v8H4z" 24+ ></path> 25+ </svg> 26+ <span>{text}</span> 27+ </div> 28+ ); 29+}
新しくLoadingSpinner.tsxというファイルを作成し、ローディング中に表示するスピナーのUIコンポーネントを定義しています。このコンポーネントは、SVG(Scalable Vector Graphics)を使って回転するアニメーションを表現しています。Tailwind CSSのanimate-spinクラスによって、このSVGが回転します。また、propsとしてtextを受け取れるようになっており、呼び出し元で「送信中...」のような任意のテキストを指定できます。指定がない場合は、デフォルトで「読み込み中...」と表示されます。このようにコンポーネント化することで、他の場所でも同じスピナーを簡単に再利用できます。
変更点: ローディング状態を管理するstateの追加
/src/app/contact/page.tsx1 import { useState } from 'react'; 2 import { useFlashMessage } from '@/app/context/FlashMessageContext'; 3 import { z } from 'zod'; 4+import LoadingSpinner from '@/app/components/LoadingSpinner'; 5 6 const schema = z.object({ 7 name: z.string().min(2, { message: '名前は2文字以上で入力してください' }), 8 const [form, setForm] = useState({ name: '', email: '', message: '' }); 9 const [errors, setErrors] = useState<{ name?: string; email?: string; message?: string }>({}); 10 const { showMessage } = useFlashMessage(); 11+ const [loading, setLoading] = useState(false)
まず、先ほど作成したLoadingSpinnerコンポーネントをimport文で読み込んでいます。これにより、このページコンポーネント内でスピナーが使用できるようになります。
次に、ReactのuseStateフックを使って、ローディング状態を管理するための新しいstate変数loadingを定義しています。loadingはtrue(ローディング中)かfalse(待機中)のどちらかの値を持ちます。useState(false)のように初期値をfalseに設定しているのは、ページが最初に表示された時点ではまだ何も処理が始まっていないためです。このloadingというstateの値を変更することで、UIの表示を動的に切り替えます。
変更点: フォーム送信開始時にローディング状態を更新
/src/app/contact/page.tsx1 const handleSubmit = async (e: React.FormEvent) => { 2 e.preventDefault(); 3 setErrors({}); 4+ setLoading(true); 5 6 const result = schema.safeParse(form);
フォームの送信ボタンがクリックされたときに実行されるhandleSubmit関数の冒頭で、setLoading(true)を呼び出しています。これにより、時間のかかる非同期処理(APIへのデータ送信など)が開始される直前に、loadingのstateがtrueに更新されます。この更新がトリガーとなり、ローディング中であることを示すUIに切り替わります。
変更点: 処理完了時にローディング状態をリセット
/src/app/contact/page.tsx1 }); 2 setErrors(fieldErrors); 3+ setLoading(false); 4 return; 5 } 6 7 } 8 } catch (err) { 9 showMessage('エラーが発生しました。もう一度お試しください。', 'error'); 10+ } finally { 11+ setLoading(false); 12 } 13 };
ここでは、時間のかかる処理が完了した後に、ローディング状態をfalseに戻す処理を追加しています。
まず、フォームの入力値チェック(バリデーション)でエラーが見つかった場合、エラーメッセージを表示すると同時にsetLoading(false)を実行します。これにより、処理を中断してすぐにローディング表示を解除します。
次に、try...catch構文にfinallyブロックを追加しています。finallyブロック内の処理は、tryブロック内の処理が成功しても、catchブロックでエラーが捕捉されても、必ず最後に実行されます。ここにsetLoading(false)を記述することで、API通信が成功した場合でも、予期せぬエラーが発生した場合でも、確実にローディング状態をfalseに戻し、ユーザーが次の操作を行えるようにします。
変更点: ローディング状態に応じたボタンの無効化とスタイル変更
/src/app/contact/page.tsx1 <button 2 type="submit" 3- className="w-full bg-blue-500 text-white py-2 px-4 rounded hover:bg-blue-600 transition" 4+ className={`w-full text-white py-2 px-4 rounded transition ${loading ? 'bg-blue-300 cursor-not-allowed' : 'bg-blue-500 hover:bg-blue-600'}`} 5+ disabled={loading} 6 >
送信ボタンの<button>タグに2つの変更を加えています。
1つ目はdisabled={loading}です。disabled属性は、trueが設定されるとその要素を無効化します。ここではloading stateの値を直接渡しているので、loadingがtrue(ローディング中)のときはボタンがクリックできなくなります。これにより、ユーザーが処理中にボタンを何度もクリックしてしまう「多重送信」を防ぐことができます。
2つ目はclassNameの変更です。ここではテンプレートリテラルと三項演算子を使い、loading stateの値に応じて適用するCSSクラスを動的に切り替えています。loadingがtrueのときは、背景色を薄い青(bg-blue-300)にし、マウスカーソルを禁止マーク(cursor-not-allowed)にすることで、ボタンが無効であることを視覚的に伝えています。loadingがfalseのときは、通常時のスタイル(bg-blue-500 hover:bg-blue-600)を適用します。
変更点: ローディング状態に応じたボタンの表示内容の切り替え
/src/app/contact/page.tsx1- 送信 2+ {loading ? <LoadingSpinner text="送信中..." /> : '送信'} 3 </button>
ボタンの内部に表示する内容を、loading stateの値に応じて切り替えるように変更しています。ここでも三項演算子 (条件 ? A : B) が使われています。
loadingがtrue(ローディング中)の場合は、先ほど作成した<LoadingSpinner />コンポーネントを表示します。このとき、text propsに "送信中..." という文字列を渡して、スピナーの横に表示されるテキストをカスタマイズしています。
一方、loadingがfalse(待機中)の場合は、これまで通り「送信」というテキストを表示します。これにより、ユーザーは現在の処理状況を一目で把握することができます。
おわりに
今回は、useStateフックでローディング状態を管理し、フォーム送信などの処理中にスピナーを表示する方法を学びました。非同期処理の開始時に状態をtrueに更新し、成功・失敗にかかわらずfinallyブロックでfalseに戻すのが確実な実装のポイントです。このローディング状態に応じて、ボタンのdisabled属性や表示内容を動的に切り替えることで、ユーザーに処理状況を分かりやすく伝えられます。この実装は多重送信の防止にも繋がり、アプリケーションの使いやすさを向上させる実践的なテクニックなので、ぜひ活用してみてください。