Next.js 14/15の「魔法の杖」を捨てろ:Server ActionsとReact Queryの正しい境界線
Next.jsのServer Actionsを「夢のオールインワン・ソリューション」だと勘違いしているなら、今すぐ目を覚ませ。
App Routerが登場して以来、「API Routeを作るのが面倒だから」という理由で、データ取得から更新まで全てをServer Actionsに詰め込む現場を嫌というほど見てきた。だが、現実は残酷だ。責務が曖昧になったフロントエンドは、コンポーネントのレンダリングライフサイクルとサーバーのシリアライズ制約に殺され、メンテナンス不可能なスパゲッティコードへと変貌する。
結論から言う。Server Actionsは「副作用(Mutation)」に専念させろ。データフェッチとキャッシュ管理は、React Query(TanStack Query)の聖域だ。
1. なぜ「全部Server Actions」が地獄への片道切符なのか
Server Actionsは確かに革命的だ。だが、これをデータフェッチに多用すると、「コンポーネントの責務」が崩壊する。
本来、UIコンポーネントは「表示」に集中すべきだ。しかし、Server Actionsでデータ取得を行うと、コンポーネント内でサーバーサイドの実行コンテキストとクライアントの状態が混ざり合い、以下の問題が噴出する。
- レンダリングの不一致: サーバーコンポーネント経由のデータ取得と、クライアント側での動的更新のタイミングが食い違い、UXがガタつく。
- キャッシュ制御の複雑化: `revalidatePath` を連打する設計は、スケールしない。特定のデータだけをピンポイントで更新したい場合、React Queryのような「クエリキーによる宣言的なキャッシュ管理」がないと、結局画面全体を再レンダリングする羽目になる。
2. 賢いエンジニアは「疎結合」を選ぶ
Server Actionsはあくまで「サーバーへのコマンド」であり、React Queryは「クライアントの状態管理エンジン」だ。これらを組み合わせることで、「サーバーサイドのストリーミングによる初期表示の高速化」と「クライアント側のインタラクティブな状態制御」を両立できる。
実践:Server Actions × React Query の最適解
// actions/user.ts
'use server'
import { z } from 'zod'
// 1. Zodでバリデーションを徹底し、DTO(Data Transfer Object)として型を確定させる
const UpdateUserSchema = z.object({
name: z.string().min(1),
})
export async function updateUserName(data: unknown) {
const parsed = UpdateUserSchema.parse(data)
// データベース更新処理
const user = await db.user.update({ data: { name: parsed.name } })
// 戻り値はプリミティブなJSONに近い形にする(シリアライズの罠を回避)
return { id: user.id, name: user.name }
}
// components/ProfileForm.tsx
'use client'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { updateUserName } from '@/actions/user'
export function ProfileForm() {
const queryClient = useQueryClient()
const { mutate } = useMutation({
mutationFn: (name: string) => updateUserName({ name }),
onSuccess: (updatedUser) => {
// 楽観的更新の同期やキャッシュの無効化をここで行う
queryClient.setQueryData(['user'], updatedUser)
}
})
return <button onClick={() => mutate('新しい名前')}>更新</button>
}
3. 「シリアライズの呪い」と戦うための鉄則
Server Actionsは引数に「シリアライズ可能なデータ」しか受け付けない。`Date`型や複雑なクラスインスタンスをそのまま投げると、実行時に `DataCloneError` で爆死する。
- 鉄則1: 複雑なオブジェクトは渡すな。プリミティブな値か、JSON互換の構造体(DTO)のみに絞る。
- 鉄則2: 必ず `Zod` 等で入力値を検証する。サーバー側で型を再定義するのではなく、共有のバリデーションスキーマを持たせるのが現代の正攻法だ。
4. リアルタイム更新:幻想を追いかけるな
「Server ActionsがあればWebSocketは不要」という意見があるが、それは大規模アプリを知らない者の戯言だ。
Server Actionsはリクエスト・レスポンスの単発通信だ。これを無理やりポーリング(短時間でのリクエスト連打)でリアルタイムっぽく見せようとすると、サーバーのコネクションが枯渇し、Next.jsのキャッシュ戦略(ISRなど)と衝突してキャッシュが正しく機能しなくなる。
- リアルタイムが必要なら: 素直に `Pusher` や `Supabase Realtime` を使え。
- 状態同期で十分なら: React Queryの `staleTime` と `gcTime` を調整し、適切に再取得(Refetch)させるだけでUXは十分向上する。
5. 設計原則:統合のための黄金律
今後、Next.jsのバージョンが上がっても耐えうるコードを書くための原則を刻んでおけ。
| 責務 | ツール | 役割 |
|---|---|---|
| 副作用 (Mutation) | Server Actions | DB更新、認証、メール送信などの「コマンド」 |
| データ取得 (Query) | React Query | キャッシュ管理、ローディング状態、楽観的更新 |
| バリデーション | Zod | Server Actionsの入り口での型安全確保 |
まとめ:設計のチェックリスト
1. Server Actionsは「薄く」保て: ロジックは別ファイル(Service層)に逃がし、Actionsはただのインターフェースにする。 2. 状態管理はReact Queryに託せ: サーバーから返ってきたデータは、即座にReact Queryのキャッシュに流し込む。 3. 副作用の副作用を考える: `revalidatePath` は最終手段。キャッシュのキーをうまく設計し、React Query側で制御できるようにする。エンジニアリングとは、便利そうな道具を何でもかんでも使うことではない。「どこで線を引くか」を決めることだ。その境界線こそが、数年後の自分を助ける唯一の防波堤になる。