Next.js App Routerの深淵:Server ActionsとReact Queryの「危うい共存」を制する

Next.js App Routerの深淵:Server ActionsとReact Queryの「危うい共存」を制する

Next.js 14/15のApp Routerを触っていて、深夜にふと手を止めたことはないか。「Server Actionsだけで完結させるべきか、それともReact Query(TanStack Query)を導入すべきか」。この境界線を見誤ると、UIはガタつき、キャッシュは不整合を起こし、最終的には「なぜ動いているのか誰にも分からない」コードの墓場が出来上がる。

今日は、この「魔の領域」で泥沼にハマり、何度もキーボードを投げ出しそうになった末に見つけた、実戦的な解法を共有する。


1. なぜ「Server Actionsだけ」では破綻するのか

「API Routeを書かなくていい」「型安全」という甘い言葉に誘われて、Server ActionsだけでUIを構築しようとすると、必ず以下の壁にぶち当たる。

汚物化したコードの断片

例えば、フォーム送信に伴う「ロード中のスピナー表示」「エラーハンドリング」「成功後の即時UI更新」をServer Actionsだけで書こうとすると、コンポーネント内は管理不能な`useState`の海になる。

// 悪夢の例:Server Actions単体での状態管理
export default function TodoForm() {
  const [loading, setLoading] = useState(false); // 状態1
  const [error, setError] = useState(null);      // 状態2
  const [data, setData] = useState(null);        // 状態3

  const handleSubmit = async (formData: FormData) => {
    setLoading(true); // 手動での状態管理が散乱する
    try {
      await addTodo(formData);
      // 画面更新のために再取得が必要だが、ここからどう制御する?
    } catch (e) {
      setError(e);
    } finally {
      setLoading(false);
    }
  };
  // ...以下、UIの条件分岐地獄
}

このアプローチは、小規模なフォームならまだしも、インタラクティブなアプリでは「ローディング状態」や「楽観的アップデート」の管理が手動となり、コードの保守性が壊滅する。


2. ベストプラクティス:役割の厳格な分離

たどり着いた結論はシンプルだ。「Server Actionsはバックエンドのインターフェース」「React Queryはフロントエンドの状態管理」と役割を割り切る。

役割分担の設計図

機能採用ツール理由
初期データ取得RSCSEOと初期表示速度の最大化
データ更新Server ActionsDB操作の簡潔さと型安全性
状態管理・UI更新React Query楽観的アップデート・キャッシュ制御

3. 実践:Server ActionsをReact Queryで包む

Server Actionsを`mutationFn`としてReact Queryに渡すことで、Server Actionsの「手軽さ」とReact Queryの「UX制御力」を両立できる。

// hooks/useTodos.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { addTodo } from '@/actions/todo';

export function useAddTodo() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: addTodo, // Server Actionをそのまま渡す
    onMutate: async (newTodo) => {
      await queryClient.cancelQueries({ queryKey: ['todos'] });
      // 楽観的アップデート:レスポンスを待たずにUIを書き換える
      queryClient.setQueryData(['todos'], (old: any) => [...old, newTodo]);
    },
    onSettled: () => {
      // 最終的なデータ整合性を確保
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    }
  });
}

【重要】キャッシュの二重管理を回避せよ

ここで最も注意すべきは、Server Actions内で`revalidatePath`を呼ぶべきではないという点だ。

もしServer Actionで`revalidatePath`を呼び、同時にReact Queryで`invalidateQueries`を呼ぶと、App Routerのサーバーキャッシュとクライアント側のキャッシュが競合し、UIが一瞬チラつく「二重再レンダリング」が発生する。

トレードオフの受け入れ:
`revalidatePath`を捨て、React Query側にキャッシュ制御を一任するということは、「サーバー主導のキャッシュ更新」を一部放棄することを意味する。これは、JSが無効な環境や、極めて強固なキャッシュ戦略が必要な場合にはリスクとなる。しかし、現代的なSPAライクな体験をNext.jsで実現する上では、この「フロントへの権限委譲」が最も現実的な選択肢だ。


4. 型安全を極める

`useActionState`(旧`useFormState`)とReact Queryを併用する場合、型定義を疎かにしてはいけない。戻り値の型を明示することで、開発体験は劇的に向上する。

// actions/todo.tsの型定義
export type ActionResponse = {
  success: boolean;
  message?: string;
  data?: Todo;
};

export async function addTodo(prevState: any, formData: FormData): Promise<ActionResponse> {
  // ...処理
  return { success: true, data: newTodo };
}

この型を`useMutation`のジェネリクスに渡すことで、コンポーネント側で「成功した時のデータ」と「エラーハンドリング」が完全に型補完される。


結びに:コードに「生きてる実感」を

Server Actionsは強力な「武器」だ。しかし、それを直接UIに直結させると、UXという繊細な防具を破壊してしまう。

React Queryという「防具」を重ねて装備することで、初めてApp Routerの真の力が解放される。深夜のデバッグでモニターに八つ当たりしたくなった時、この記事を思い出してほしい。泥臭い試行錯誤の先には、必ずクリーンで高速なコードが待っている。

さあ、コーヒーを淹れ直して、続きを書こうぜ。