Next.js Server Actionsの深層:useTransition と useOptimistic で実現する楽観的UI更新とベストプラクティス
多くのNext.js開発者がServer Actionsを導入する際、単に「サーバーサイドでフォームを処理できる便利な機能」と捉えがちです。しかし、その真価はuseTransitionとuseOptimisticというReactフックと組み合わせることで発揮されます。これらを適切に活用しないと、ユーザーは「ボタンを押したのに何も起こらない」「UIの更新が遅い」といったストレスを感じ、結果的にアプリケーションの体験を損ねてしまいます。
この記事では、Next.js 14で導入されたServer Actionsがどのように動作するのか、そしてそれを支えるuseTransitionとuseOptimisticフックの内部動作を深く掘り下げます。フォーム送信やデータ更新時のUIの振る舞いを最適化し、ユーザー体験を劇的に向上させる楽観的UI更新の具体的な実装パターンと注意点を解説します。この記事を読めば、Server Actionsのベストプラクティスを理解し、よりスムーズなWebアプリケーション開発が可能になります。
Server Actionsとは何か?その基本と利点
このセクションでは、Server Actionsの基本的な概念、定義方法、そして従来のAPIルートと比較した利点について解説します。
Next.js Server Actionsは、サーバーサイドで直接データミューテーション(作成、更新、削除)を行うための非同期関数です。これにより、クライアントとサーバー間の明示的なAPIエンドポイント(Route Handlersなど)を定義する手間が省け、アプリケーションの内部ロジックをよりシンプルに記述できます。
Server Actionsの定義方法
Server Actionsは、"use server" ディレクティブを使って定義します。このディレクティブは、関数の先頭、またはファイル全体の先頭に記述できます。
-
関数の先頭に定義する場合: 特定の関数のみをServer Actionとしてマークします。
// app/actions.ts 'use server'; // この関数のみServer Action export async function createItem(formData: FormData) { // サーバーサイドの処理 console.log('Creating item with:', formData.get('name')); // ... データベース操作 ... return { success: true }; } -
ファイルの先頭に定義する場合: そのファイル内の全てのエクスポートされた関数がServer Actionとして扱われます。
// app/actions.ts 'use server'; // このファイル内の全てのエクスポートがServer Action export async function createItem(formData: FormData) { /* ... */ } export async function updateItem(id: string, data: any) { /* ... */ }
Server ActionsはServer Components内でインラインで定義することも可能ですが、Client Componentsから呼び出す場合は、"use server" ディレクティブをトップレベルに持つ別ファイル(例: app/actions.ts)に定義し、それをインポートする必要があります。
<form action> との連携
Server Actionsの最も一般的な使い方は、HTMLの<form>要素のaction属性に直接渡すことです。これにより、JavaScriptが無効な環境でもフォーム送信が機能するプログレッシブエンハンスメントが自動的に提供されます。
// app/page.tsx (Server Component)
import { createTodo } from './actions';
export default function HomePage() {
return (
<form action={createTodo}>
<input type="text" name="todo" />
<button type="submit">Add Todo</button>
</form>
);
}
このとき、フォームの入力値は自動的にFormDataオブジェクトとしてServer Actionに渡されます。
Server ActionsのメリットとAPIルートとの使い分け
Server Actionsの最大のメリットは、APIボイラープレートの削減、型安全性、そしてNext.jsのキャッシング・再検証アーキテクチャとの統合です。
| 特徴 | Server Actions | Route Handlers (API Routes) |
|---|---|---|
| 用途 | アプリケーション内部からのデータミューテーション | 外部からのHTTPエンドポイント公開、データフェッチング |
| HTTPメソッド |
POST のみ (内部的に) |
全てのHTTPメソッド (GET, POST, PUT, DELETE など) |
| ボイラープレート | 少ない (URL定義、リクエスト/レスポンスのシリアライズが不要) | 多い (URL定義、リクエスト/レスポンスのシリアライズが必要) |
| プログレッシブエンハンスメント | サポート | なし (JavaScript必須) |
| 型安全性 | 高い (TypeScriptで関数の引数・戻り値の型を定義できる) | 低い (手動でリクエスト/レスポンスの型を定義する必要がある) |
| キャッシュ再検証 |
revalidatePath, revalidateTag と統合 |
手動でキャッシュヘッダーを制御する必要がある場合が多い |
ベストプラクティスとしては、アプリケーション内部からのデータミューテーションにはServer Actionsを、外部に公開するAPIや複雑なデータフェッチングにはRoute Handlersを使い分けることです。
useTransition で実現するローディングUIと保留状態の管理
このセクションでは、useTransitionフックを使って、Server Actionが実行されている間のUIの保留状態を管理し、ユーザーに適切なフィードバックを提供する方法を解説します。
Server Actionは非同期処理であるため、完了には時間がかかります。この間、UIが何の反応も示さないと、ユーザーは「フリーズしたのでは?」「もう一度クリックすべきか?」と混乱してしまいます。useTransitionは、このような非緊急のUI更新(トランジション)中に、UIをブロックせずに保留状態を示すためのReactフックです。
useTransition の基本的な使い方
useTransitionは、isPending(保留中かどうかを示すブール値)とstartTransition(トランジションを開始する関数)の2つの要素を返します。
// app/components/add-todo-form.tsx
'use client';
import { useTransition, useRef, useState } from 'react';
import { createTodo } from '@/app/actions';
export function AddTodoForm() {
const [isPending, startTransition] = useTransition(); // useTransitionを初期化
const [message, setMessage] = useState<string | null>(null);
const formRef = useRef<HTMLFormElement>(null);
const handleSubmit = async (formData: FormData) => {
setMessage(null);
// Server Actionの呼び出しをstartTransitionでラップ
startTransition(async () => {
const result = await createTodo(formData);
if (result.success) {
formRef.current?.reset();
setMessage(result.message);
} else {
setMessage(result.error);
}
});
};
return (
<form ref={formRef} action={handleSubmit} className="flex flex-col gap-2 p-4 border rounded shadow-md">
<input
type="text"
name="todo"
placeholder="新しいTodoを追加"
className="border p-2 rounded focus:ring-blue-500 focus:border-blue-500"
disabled={isPending} // isPendingがtrueの間は入力とボタンを無効化
/>
<button
type="submit"
className="bg-blue-600 text-white p-2 rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isPending} // isPendingがtrueの間は入力とボタンを無効化
>
{isPending ? '追加中...' : 'Todoを追加'} {/* isPendingに応じてボタンのテキストを変更 */}
</button>
{message && (
<p className={`mt-2 text-sm ${message.includes('成功') ? 'text-green-600' : 'text-red-600'}`}>
{message}
</p>
)}
</form>
);
}
上記の例では、startTransitionでcreateTodo Server Actionをラップしています。Server Actionが実行されている間、isPendingはtrueになり、それを利用して入力フィールドとボタンを無効化し、「追加中...」というテキストを表示することで、ユーザーに明確なフィードバックを提供しています。
useTransition の重要性
useTransitionは、特に遅延を伴うデータミューテーションにおいて、ユーザー体験を損なわずにUIの応答性を維持するために不可欠です。これにより、UIのフリーズを防ぎ、ユーザーはアプリケーションがバックグラウンドで処理を行っていることを理解できます。
useOptimistic による究極のユーザー体験:楽観的UI更新
このセクションでは、useOptimisticフックを使って、Server Actionの結果を待たずにUIを即座に更新する「楽観的UI更新」の手法と、そのメリット・デメリットを解説します。
ユーザーはウェブアプリケーションの応答性に対して高い期待を持っています。特に「いいね」ボタンのクリックやタスクの完了など、即座にUIに反映されるべき操作に対して、サーバーの応答を待つことはUXの低下につながります。useOptimisticは、このようなケースでサーバーからの確認を待つことなくUIを「楽観的に」更新し、バックグラウンドでサーバーとの同期を行うためのReactフックです。
useOptimistic の基本的な使い方
useOptimisticは、現在の状態と、楽観的に更新された状態の2つを管理します。
-
state: 現在の実際の状態(サーバーからの最終的な状態)。 -
optimisticState: 楽観的に更新された状態。 -
updateFn: 楽観的更新を適用するための関数。
useOptimisticは、[optimisticState, setOptimisticState]を返します。setOptimisticState関数は、Server Action内で呼び出すことで、UIを即座に更新できます。サーバーからの応答が返ってきた後、useOptimisticは自動的にstateとoptimisticStateを同期させます。もしServer Actionが失敗した場合、optimisticStateは自動的に元のstateにロールバックされます。
具体例: 「いいね」ボタンの楽観的UI更新
// app/components/like-button.tsx
'use client';
import { useOptimistic, useTransition } from 'react';
import { toggleLike } from '@/app/actions';
interface Post {
id: string;
likes: number;
likedByUser: boolean;
}
export function LikeButton({ post }: { post: Post }) {
const [isPending, startTransition] = useTransition();
// useOptimisticを初期化。postが初期状態。
// updateFnは、新しいliked状態を受け取り、それに基づいてoptimisticPostの状態を計算する。
const [optimisticPost, setOptimisticPost] = useOptimistic(
post,
(state, liked: boolean) => ({
...state,
likedByUser: liked,
likes: liked ? state.likes + 1 : state.likes - 1,
})
);
async function handleClick() {
startTransition(async () => {
// サーバーからの応答を待たずに、UIを即座に更新
setOptimisticPost(!optimisticPost.likedByUser); // optimisticPostが即座に更新される
// サーバーにリクエストを送信
const result = await toggleLike(post.id, optimisticPost.likedByUser);
// サーバー側でエラーが発生した場合、useOptimisticが自動的にUIを元の状態にロールバックする
if (!result.success) {
console.error('Failed to toggle like on server. Rolling back UI.');
// 明示的なロールバックは通常不要だが、デバッグ用にメッセージ表示など
}
});
}
return (
<button
onClick={handleClick}
className={`flex items-center gap-1 p-2 rounded-full transition-colors duration-200
${optimisticPost.likedByUser ? 'text-red-500 bg-red-100' : 'text-gray-500 hover:bg-gray-100'}
${isPending ? 'opacity-70 cursor-not-allowed' : ''}`}
disabled={isPending}
>
<svg
xmlns="http://www.w3.org/2000/svg"
className={`h-5 w-5 ${optimisticPost.likedByUser ? 'fill-current' : 'stroke-current'}`}
viewBox="0 0 24 24"
>
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
</svg>
{optimisticPost.likes} Likes
</button>
);
}
この例では、handleClickが呼び出されると、まずsetOptimisticPost(!optimisticPost.likedByUser)によってUIが即座に更新されます。これにより、ユーザーはボタンをクリックした瞬間に「いいね」が反映されたように感じます。その後、toggleLike Server Actionが実行され、サーバーとの同期が行われます。もしサーバーでの更新が失敗した場合、useOptimisticは自動的にUIを元の状態に戻します。
楽観的UI更新のトレードオフと注意点
- メリット: ユーザー体験の向上、アプリケーションの応答性向上。
- デメリット: サーバーでの処理に失敗した場合、UIが一時的に誤った状態を表示する可能性があります。この「一貫性の欠如」を許容できるユースケースに限定すべきです。
-
注意点:
- サーバー側の処理が複雑で失敗する可能性が高い場合や、ユーザーにとって重大な結果をもたらす操作(例: 決済処理)には慎重に適用すべきです。
-
setOptimisticPostはServer Action内で呼び出す必要があります。
Server Actionsにおけるエラーハンドリングとセキュリティ
Server Actionsを実運用で使う上で、エラーハンドリングとセキュリティは非常に重要です。このセクションでは、よくあるエラーパターンとその回避策、そしてセキュリティ上のベストプラクティスを解説します。
よくあるエラーとその回避策
-
Failed to find Server ActionまたはServer Reference ID did not match the expected format- 原因: クライアントとサーバーが異なるビルドを使用している場合に発生します。特にデプロイ直後や、複数のサーバーインスタンス間でビルドが同期されていない場合に起こりやすいです。Server ActionsのIDはセキュリティ上の理由から非決定的に生成されます。
-
回避策:
- クライアントのキャッシュ(ブラウザキャッシュ)をクリアする。
- 自己ホスト環境では、全てのサーバーインスタンスが同じビルド出力と、
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY環境変数で指定された一貫した暗号化キーを使用していることを確認する。 - Vercelにデプロイしている場合、VercelのSkew Protection機能が有効になっていることを確認する。
-
Server Action内でエラーを
throwすることによるUXの低下-
原因: Server Action内で直接エラーを
throwすると、ReactのUnhandled Errorとして扱われ、最も近いError Boundaryがトリガーされます。これにより、フォームデータが失われたり、ユーザーに一般的なエラーメッセージしか表示されず、回復パスが提供されません。 -
回避策: エラーを
throwするのではなく、Server Actionの戻り値にエラー情報を含めるパターンを採用します。
'use server'; export async function createTodo(formData: FormData) { const todoText = formData.get('todo') as string; if (!todoText || todoText.trim() === '') { return { success: false, error: 'Todoテキストは必須です。' }; // エラーをオブジェクトとして返す } // ... 成功時の処理 ... return { success: true, message: 'Todoが作成されました!' }; }クライアント側では、この戻り値をチェックして適切なメッセージを表示します。
-
原因: Server Action内で直接エラーを
セキュリティ上のベストプラクティス
Server Actionsはサーバーサイドで実行されますが、クライアントから呼び出されるため、セキュリティには特に注意が必要です。Server Actionsを「公開HTTPエンドポイント」として扱うべきです。
-
入力値のサーバーサイド検証: クライアントサイドでの検証はユーザー体験のためですが、セキュリティのためにはServer Action内でも必ず入力値の検証を行います。悪意のあるユーザーはクライアントサイドの検証をバイパスできるためです。Zodのようなスキーマバリデーションライブラリを活用すると、型安全な検証が可能です。
// app/actions.ts 'use server'; import { z } from 'zod'; const todoSchema = z.object({ todo: z.string().min(1, { message: 'Todoテキストは必須です。' }), }); export async function createTodoSafe(formData: FormData) { const parsed = todoSchema.safeParse({ todo: formData.get('todo'), }); if (!parsed.success) { return { success: false, error: parsed.error.issues[0].message }; } const { todo } = parsed.data; // ... データベース操作 ... return { success: true, message: `Todo "${todo}" created!` }; } -
認証・認可の実施: Server Action内でユーザーの認証状態を確認し、適切な認可ロジックを適用します。例えば、特定のユーザーしか更新できないリソースに対する操作は、そのユーザーが実際にその権限を持っているかを確認する必要があります。
'use server'; import { auth } from '@/lib/auth'; // 認証ライブラリから現在のユーザー情報を取得 export async function updateProtectedResource(data: any) { const session = await auth(); // 現在のセッションを取得 if (!session || !session.user) { return { success: false, error: '認証が必要です。' }; } // ここでsession.user.idなどを使って認可ロジックを実装 // 例: if (resource.ownerId !== session.user.id) { return { success: false, error: '権限がありません。' }; } // ... データベース更新処理 ... return { success: true }; } -
キャッシュの再検証: データが変更された後には、
revalidatePathやrevalidateTagを使用して関連するキャッシュを適切に再検証し、UIが最新の状態を反映するようにします。これにより、古いデータが表示され続けることを防ぎます。// app/actions.ts 'use server'; import { revalidatePath } from 'next/cache'; export async function createTodo(formData: FormData) { // ... データベース操作 ... revalidatePath('/'); // ルートパスのキャッシュを再検証 return { success: true }; }
まとめと次の一歩
この記事では、Next.js Server Actionsの基本から、useTransitionとuseOptimisticを使った楽観的UI更新、そしてエラーハンドリングとセキュリティのベストプラクティスまでを深掘りしました。
重要なポイント:
- Server Actions: クライアントから直接サーバーサイドのデータミューテーションを呼び出すための強力な機能。APIボイラープレートを削減し、プログレッシブエンハンスメントをサポートします。
-
useTransition: Server Actionsのような非緊急の更新中にUIの保留状態を管理し、ローディングインジケーター表示などでユーザーにフィードバックを提供します。 -
useOptimistic: サーバーからの応答を待たずにUIを即座に更新し、アプリケーションの応答性を劇的に向上させる「楽観的UI更新」を可能にします。サーバーエラー時には自動的にロールバックされます。 -
エラーハンドリング: Server Action内で
throwするのではなく、戻り値にエラー情報を含めることで、ユーザーフレンドリーなエラー処理を実現します。 - セキュリティ: Server Actionsは公開APIエンドポイントとして扱い、入力値のサーバーサイド検証、認証・認可の徹底が必要です。
これらの技術を組み合わせることで、Next.jsアプリケーションのユーザー体験を大幅に向上させ、開発効率も高めることができます。ぜひご自身のプロジェクトで試してみてください。
さらに深く学びたい場合は、Next.jsの公式ドキュメントにあるServer Actions、useTransition、useOptimisticの各ページを参照し、最新の情報や詳細なユースケースを確認することをお勧めします。特に、React 19で導入予定のuseActionState(旧useFormStatus)など、関連するReactフックにも注目すると良いでしょう。