「Server Actionsさえあればいい」と嘯いていた俺が、プロダクトを崩壊させた話

「Server Actionsさえあればいい」と嘯いていた俺が、プロダクトを崩壊させた話

「全部Server Actionsで完結させれば、React Queryなんて不要じゃね?」

半年ほど前、俺はNext.js 14のApp Routerにどっぷり浸かり、そんな甘い幻想を抱いていた。結果は惨敗だ。担当していたSaaSのダッシュボードで、ユーザーが「タスク完了」ボタンを押した瞬間にUIがチカチカと再レンダリングを繰り返し、挙句の果てに「データが更新されていない」という不具合報告が殺到した。

原因は単純。`revalidatePath`によるキャッシュの全破棄と、Reactのコンポーネントツリーの再描画が同期せず、UIが「サーバーの最新状態」と「クライアントの現在地」の狭間で迷子になっていたからだ。

深夜3時、真っ黒な画面を見つめながら俺は悟った。「Server Actionsは魔法じゃない。ただのRPC(リモートプロシージャコール)に過ぎないんだ」と。


1. なぜ「Server Actionsだけ」で詰むのか

Server Actionsは強力だ。しかし、あれは「状態を更新する」ための手段であって、「UIの状態を管理する」ためのものではない。

俺がやらかしたのは、タスク一覧を表示するコンポーネントで、Server Actionsを呼ぶたびに`revalidatePath`を叩き、全データを再取得し直すという力技だった。

// 爆死した原因:再レンダリングの嵐
'use server'
export async function toggleTodo(id: string) {
  await db.todo.update({ where: { id }, data: { done: true } })
  revalidatePath('/dashboard') // これが画面全体の再フェッチをトリガーする
}

この実装だと、ネットワークが少しでも遅延すれば、ボタンをクリックした瞬間にローディングスピナーが画面全体を覆う。ユーザーが求めているのは「ボタンを押した瞬間のサクサクした体験」なのに、俺は「ページ遷移と同等の重さ」を強いていた。

2. Server ActionsとTanStack Queryの「理想の分担」

結論から言おう。Server Actionsは「副作用(Mutation)」の実行担当、TanStack Query(React Query v5)は「状態(Query/Cache)の司令塔」と割り切るのが、今のベストプラクティスだ。

推奨されるディレクトリ構成

ビジネスロジックをコンポーネントやActionに直書きするな。`lib/services`に分離し、Actionはその薄いラッパーに徹する。

src/
 ├── app/actions/
 │    └── todo-actions.ts    // Server Actions(エントリポイント)
 ├── lib/services/
 │    └── todo-service.ts    // 純粋なビジネスロジック(DB操作)
 └── hooks/
      └── use-todos.ts       // TanStack Queryのカスタムフック

楽観的更新による「サクサク感」の演出

Server Actionsでサーバーを叩きつつ、TanStack Queryの`onMutate`でUIを先読み更新する。これが「モダンWeb」の正解だ。

// hooks/use-todos.ts
export function useToggleTodo() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: toggleTodoAction, // Server Actionを渡す
    onMutate: async (id) => {
      await queryClient.cancelQueries({ queryKey: ['todos'] });
      const previous = queryClient.getQueryData(['todos']);
      
      // 楽観的更新:サーバーの応答を待たずに即時反映
      queryClient.setQueryData(['todos'], (old: any[]) => 
        old.map(t => t.id === id ? { ...t, done: true } : t)
      );
      return { previous };
    },
    onError: (err, id, context) => {
      queryClient.setQueryData(['todos'], context?.previous);
    },
    onSettled: () => {
      // 最後にサーバーの正確な状態で上書き
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    }
  });
}

3. Next.js 15 / React 19時代の「Action」の立ち位置

Next.js 15(React 19)になり、`useActionState`や`useOptimistic`といったフックが洗練された。だが、それでもTanStack Queryを捨てるべきではない理由は、「キャッシュのライフサイクル管理」にある。

React 19のActionは「送信中の状態」を扱うには優秀だが、複雑なフロントエンドの状態管理(例えば、複数のフィルタリング条件とページネーションが絡み合うリスト)を制御する道具としては、TanStack Queryの宣言的なAPIにまだ分がある。

機能Server Actions (React 19)TanStack Query v5
主な用途サーバー側のデータ更新・認証クライアント側のキャッシュ・同期
楽観的更新`useOptimistic` (手動制御)`onMutate` (自動・宣言的)
キャッシュ管理基本なし (再フェッチのみ)高度な無効化・ポーリング
適した場所フォーム送信、DB更新リスト表示、ダッシュボード

4. 現場で死なないための教訓

最後に、俺が血反吐を吐いて学んだ「落とし穴」を共有しておく。

1. バリデーションの境界線を守れ:
Server Action内で`zod`を使う際、`import { z } from 'zod'`をサーバー専用ファイルに閉じ込めろ。クライアントコンポーネントにZodのスキーマを直接インポートすると、バンドルサイズが肥大化し、最悪の場合「Module not found」の亡霊に憑りつかれる。

2. `revalidatePath`は最後の手段:
`revalidatePath`は強力すぎる。特定のデータだけを更新したいなら、TanStack Queryの`invalidateQueries`を使うのが、無駄な再レンダリングを避ける唯一の道だ。

3. 枯れた技術を信じろ:
TanStack Queryは「枯れた技術」ではない。Reactの非同期状態管理における「完成された標準」だ。最新のNext.js機能と、この安定したライブラリを組み合わせることこそが、技術的負債を最小限にする唯一の近道だと確信している。

流行りの技術を鵜呑みにせず、自分の手でコードを書き、その挙動を疑え。エンジニアとしての生存戦略は、結局のところ「いかに泥臭く、現実的な解を導き出すか」に尽きるんだ。