「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機能と、この安定したライブラリを組み合わせることこそが、技術的負債を最小限にする唯一の近道だと確信している。
流行りの技術を鵜呑みにせず、自分の手でコードを書き、その挙動を疑え。エンジニアとしての生存戦略は、結局のところ「いかに泥臭く、現実的な解を導き出すか」に尽きるんだ。