Next.js 14/15の「Server Actions至上主義」が陥る罠と、TanStack Queryによる解法
「全部Server Actionsで書けばシンプルになる」――そう信じていた時期が僕にもありました。App Routerの登場以降、API Routeを捨ててServer Actionsに全振りした結果、複雑なダッシュボードを前にして「画面の更新が追いつかない」「キャッシュの整合性が取れない」という地獄を見たエンジニアは多いはずです。
結論から言います。Server Actionsは「更新のトリガー」に徹し、クライアントの状態管理はTanStack Queryに任せる。 この境界線を曖昧にすると、アプリは確実に破綻します。
1. なぜ「Server Actions単体」ではUXが破綻するのか
Next.jsの`revalidatePath`や`revalidateTag`は強力ですが、これらはあくまで「サーバー側のキャッシュ」を無効化する機能です。一方で、フロントエンドの状態管理(React Queryのキャッシュ)は別のレイヤーで動いています。
#### 発生する「二重管理」のメカニズム
1. Server Action実行: `revalidatePath`でサーバー側のData Cacheを更新。
2. クライアントの乖離: ブラウザ側は「古いキャッシュ」を握ったまま。
3. UXの劣化: ユーザーが操作してもUIが更新されず、リロードして初めて反映される。
この乖離を埋めるために`useEffect`で無理やり再取得を走らせるようなコードは、保守性の悪夢です。
| レイヤー | 役割 | キャッシュ管理 |
|---|---|---|
| Server Actions | DB更新・副作用の実行 | Next.js Data Cache (revalidate) |
| TanStack Query | データの取得・同期 | Client-side Cache (invalidate) |
2. 【最適解】Server Actions × TanStack Query の連携パターン
理想的な構成は、Server Actionsで更新を行い、その直後にTanStack Queryのキャッシュを明示的に無効化することです。
'use client'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { updateUserData } from './actions'
export function ProfileForm() {
const queryClient = useQueryClient()
const { mutate } = useMutation({
mutationFn: updateUserData,
onSuccess: () => {
// サーバー側の再検証とクライアントのキャッシュ無効化を同期させる
queryClient.invalidateQueries({ queryKey: ['user'] })
}
})
return <form action={(formData) => mutate(formData)}>...</form>
}
#### 楽観的更新(Optimistic Updates)で体感速度を極める
ユーザーは「ボタンを押した瞬間」にUIが変わることを期待しています。サーバーの応答を待つ必要はありません。
onMutate: async (newData) => {
// 進行中のフェッチをキャンセルして競合を防ぐ(必須)
await queryClient.cancelQueries({ queryKey: ['user'] })
const previousData = queryClient.getQueryData(['user'])
// UIを即座に更新
queryClient.setQueryData(['user'], newData)
return { previousData }
},
onError: (err, _, context) => {
// 失敗時は元の状態にロールバック
queryClient.setQueryData(['user'], context?.previousData)
}
`cancelQueries`を呼ぶ理由は、バックグラウンドでの再フェッチと手動の更新が競合し、UIが一瞬チラつくのを防ぐためです。
3. App Router時代の肝:Hydrationパターンの採用
初期表示の高速化を諦めてはいけません。Server Componentsで取得したデータを、クライアント側でTanStack Queryの初期値として引き継ぐ(Hydration)のがプロの作法です。
// Server Component
async function Page() {
const data = await getUserData()
const queryClient = new QueryClient()
await queryClient.prefetchQuery({ queryKey: ['user'], queryFn: () => data })
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ProfileClientComponent />
</HydrationBoundary>
)
}
これにより、SSRでHTMLを生成しつつ、クライアント側ではTanStack Queryがそのデータを「キャッシュ済み」として引き継ぐため、「初期表示は爆速、インタラクションもサクサク」という理想のUXが完成します。
4. Next.js 14/15での使い分け:`useActionState`の導入
Next.js 15(React 19)では、`useFormState`が`useActionState`へと進化しました。フォーム送信時の状態管理がより直感的になっています。
// Next.js 15以降の推奨パターン
const [state, formAction, isPending] = useActionState(
updateUserData,
initialState
)
結論:
- データ取得: `Server Components` + `TanStack Query` (Hydration)
- データ更新: `Server Actions` + `TanStack Query` (Mutation + Invalidation)
この役割分担を徹底することで、Next.js特有のキャッシュの複雑さから解放されます。
深夜2時、コードが綺麗に整理された瞬間の静寂は格別です。Server Actionsを「魔法の杖」だと思わず、「状態管理の司令塔」としてのTanStack Queryと組み合わせる。この二刀流こそが、今のApp Routerで生き残るための唯一の道だと確信しています。