Next.js Server Actions でインラインフィードバックを実装する【App Router】

使い方・設定

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>
  );
}

useTransitionisPending(ここでは 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 が一段階上がるはずだ。

コメント