Next.js App Router で Server Actions を使い始めると、最初に直面する壁がある。フォームからアクションを呼び出すと redirect() で画面が遷移してしまい、「ボタンを押したら成功メッセージをその場で表示したい」という要件に応えられないのだ。
具体的には「トピックを次回の1on1に持ち込む」ボタンを押した瞬間にボタン表示を「持ち込み済み ✓」に切り替えたい、という場面で困った。redirect() を使うとページ全体がリロードされ、ボタンの状態変化を表現できない。
この記事では、useTransition と Server Actions の返り値を組み合わせて ページ遷移なしのインラインフィードバックを実現するパターンを、実際に 1on1 支援ツールで実装した例をもとに解説する。
📋 この記事でわかること
redirect()がインラインフィードバックを妨げる仕組みと理由- 値を返す Server Action の作り方(Union 型で成功/失敗を表現)
useTransitionを使ったクライアント側の状態管理パターンuseFormState/useActionStateとの使い分け基準- エラーハンドリングとローディング表示の実装例
- インライン削除確認フローへの応用パターン
なぜ redirect() ではインラインフィードバックができないのか
通常の Server Actions は以下のような形で使われる。
'use server';
export async function someAction(formData: FormData) {
await doSomething(formData);
revalidatePath('/path');
redirect('/path?message=success'); // ← これが原因
}
redirect() は Next.js 内部で NEXT_REDIRECT という特殊なエラーを throw することで動作する。そのため アクション内で結果を return しても呼び出し元には届かない。throw された時点でアクションの実行が中断され、ブラウザがリダイレクト先に遷移するからだ。
フォームで action={someAction} として呼び出すと常にページ遷移が発生し、URL のクエリパラメータでメッセージを伝える方法しか取れない。ボタンの見た目を「処理中…」「完了 ✓」に切り替えるような UX は、redirect() ベースの設計では実現できない。
解決策:値を返す Server Action + useTransition
答えはシンプルで、redirect() を呼ばずに結果を return する専用のアクションを作ることだ。
Step 1: 値を返す Server Action を定義する
// app/(dashboard)/sessions/actions.ts
'use server';
export async function carryTopicDirectAction(
sessionId: string,
topicTitle: string,
): Promise<{ success: true } | { error: string }> {
const { supabase, team } = await requireDashboardContext();
if (!topicTitle) return { error: 'topic-required' };
const session = await getSessionById(supabase, sessionId);
if (!session || session.team_id !== team.id) {
return { error: 'session-not-found' };
}
const newAgenda = session.agenda
? `${session.agenda}\n${topicTitle}`
: topicTitle;
await updateSession(supabase, sessionId, { agenda: newAgenda });
revalidatePath(`/sessions/${sessionId}`);
return { success: true }; // redirect ではなく値を返す
}
ポイントは2つ:
1. redirect() を使わず Union 型で成功/失敗を返す
2. revalidatePath() はそのまま使える(キャッシュ更新はしたいため)
Step 2: クライアントコンポーネントで useTransition を使う
'use client';
import { useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { carryTopicDirectAction } from '@/app/(dashboard)/sessions/actions';
export function TopicCard({ topic, sessionId }: TopicCardProps) {
const router = useRouter();
const [carried, setCarried] = useState(false);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [isCarrying, startCarry] = useTransition();
function handleCarry() {
setErrorMsg(null);
startCarry(async () => {
const result = await carryTopicDirectAction(sessionId, topic.title);
if ('success' in result) {
setCarried(true); // UI を成功状態に更新
router.refresh(); // Server Component のデータを再取得
} else {
setErrorMsg('持ち込みに失敗しました。もう一度お試しください。');
}
});
}
return (
<div>
<button
type="button"
disabled={carried || isCarrying}
onClick={handleCarry}
>
{carried ? '持ち込み済み ✓' : isCarrying ? '処理中…' : '次回に持ち込む'}
</button>
{errorMsg && <p role="alert" style={{ color: 'red' }}>{errorMsg}</p>}
</div>
);
}
useTransition の isPending(ここでは isCarrying)が true の間はトランジション中を示す。アクション実行中はボタンを disabled にして二重実行を防げる。エラー時には errorMsg に状態を持たせてその場で表示する。
なぜ useFormState ではなく useTransition を使うのか
Next.js には useFormState(React 19 では useActionState)という別のフックもある。比較すると以下の通り。
| useTransition | useFormState / useActionState | |
|---|---|---|
| フォーム不要 | ○ | △(form 要素が基本) |
| 直接引数を渡せる | ○ | △(formData 経由) |
| pending 状態 | isPending | isPending |
| 結果の受け取り | 明示的に useState で管理 | state として自動管理 |
| 複数フィールド | △(手動管理) | ○(formData でまとめて取得) |
ボタン1つのシンプルなアクションで引数を直接渡したい場合は useTransition の方が自然だ。一方、フォームに複数の入力フィールドがある場合は useFormState / useActionState の方が便利なことが多い。
ハマりやすいポイントと対処法
router.refresh() を忘れると Server Component が古いデータを表示し続ける
revalidatePath() はサーバー側のキャッシュを無効化するが、クライアント側の React ツリーは自動で再レンダリングされない。router.refresh() を呼んで初めて、Server Component が最新データを取得して再描画される。
// ❌ revalidatePath だけでは画面が更新されない
return { success: true };
// ✅ router.refresh() を合わせて呼ぶ
if ('success' in result) {
setCarried(true);
router.refresh(); // ← これが必要
}
useTransition の外で await を呼ぶと isPending が効かない
startTransition に渡すコールバック内で await するのが正しい使い方だ。外に出すと pending 状態が正しく追跡されない。
// ❌ await が startCarry の外にある
const result = await carryTopicDirectAction(sessionId, topic.title);
startCarry(() => {
if ('success' in result) setCarried(true);
});
// ✅ await を startCarry の中に入れる
startCarry(async () => {
const result = await carryTopicDirectAction(sessionId, topic.title);
if ('success' in result) setCarried(true);
});
Server Action は ‘use server’ ファイルからしかインポートできない
'use client' ディレクティブのあるファイルに 'use server' を混在させることはできない。アクションは必ず別ファイル(actions.ts など)に分離する。
応用:インライン削除確認コンポーネント
同じパターンを使って、削除ボタンにインライン確認フローを追加するケースも多い。useTransition を使わず純粋に useState で確認ステップを管理し、実際の削除は通常の Server Action(redirect() あり)に委ねるパターンだ。
'use client';
import { useState } from 'react';
interface DeleteConfirmButtonProps {
formAction: (formData: FormData) => Promise<unknown>;
data: Record<string, string>;
label: string;
}
export function DeleteConfirmButton({
formAction,
data,
label,
}: DeleteConfirmButtonProps) {
const [confirming, setConfirming] = useState(false);
if (!confirming) {
return (
<button type="button" onClick={() => setConfirming(true)} aria-label={label}>
削除
</button>
);
}
return (
<form action={formAction}>
{Object.entries(data).map(([name, value]) => (
<input key={name} type="hidden" name={name} value={value} />
))}
<span>削除しますか?</span>
<button type="submit">削除する</button>
<button type="button" onClick={() => setConfirming(false)}>
キャンセル
</button>
</form>
);
}
この DeleteConfirmButton はフォームの action に通常の Server Actions(redirect() を使うもの)をそのまま渡せる。確認フロー(ボタン表示 → 確認画面 → submit)だけをクライアント側で管理し、実際の削除処理はサーバーに委ねるシンプルな設計だ。
使い分けのまとめ
| シナリオ | 推奨パターン |
|---|---|
| 完了後に別ページへ遷移したい | redirect() を使う通常の Server Action |
| その場で成功/失敗を表示したい | 値を返す Server Action + useTransition |
| フォームに複数入力がある | useActionState(React 19)/ useFormState |
| 削除確認フローが欲しい | DeleteConfirmButton パターン |
| 楽観的更新が必要 | useOptimistic(別途解説予定) |
よくある質問
Q. Server Action の中でも try/catch でエラーをハンドリングすべきですか?
A. DB エラーや外部 API のエラーは Server Action 内で catch し、{ error: 'db-error' } のように返すのがおすすめだ。catch しないと Next.js のエラーバウンダリに伝播し、ページ全体がエラー表示になる。ユーザーに見せたいエラーは Union 型で返し、ログに残すだけでいいエラーは catch してサーバーログに出力する。
Q. useTransition は React 18 から使えますか?
A. React 18 以降で使える。Next.js App Router(Next.js 13.4 以降)で Server Actions を使う場合は React 18 が前提なので、実質的に気にしなくていい。
Q. isPending が true の間に別のアクションを呼べますか?
A. useTransition は1つのトランジションしかトラッキングできない。複数のアクションを並行管理したい場合は、それぞれ別の useTransition を用意するか、useReducer で状態を管理する。
Q. Server Actions でファイルアップロードはできますか?
A. できる。formData.get('file') で File オブジェクトを受け取れる。ただし useTransition ではなく form action で呼ぶのが標準的なパターンだ。
✅ まとめ
Server Actions の redirect() とインラインフィードバックは両立しない。ページ遷移が不要なアクション(ボタン1つの操作、状態トグル、楽観的更新など)では、値を返す Server Action + useTransition のパターンが有効だ。
実装のポイントを整理すると:
- Server Action は Union 型(
{ success: true } | { error: string })で結果を返す - クライアントでは
startTransition(async () => { ... })の中にawaitを入れる router.refresh()を忘れると Server Component が更新されない- フォームが必要な複数フィールドのケースは
useActionStateを検討する
まず手元の Next.js App Router プロジェクトで、redirect() を使っているアクションのうち「ページ遷移させなくていい」ものを探してみてほしい。そこから切り替えていくと、UX が一段階上がるはずだ。


コメント