Next.js 14/15で「React Queryは不要」と叫んだあの日、俺のブラウザは死んだ
Next.js 14のApp Routerが出た時、俺は確信した。「もう面倒なクライアントの状態管理なんていらない。Server Actionsこそが唯一の正解だ」と。
結果どうなったか? 本番環境でユーザーのブラウザは重くなり、少し複雑なダッシュボードを触ればUIがガタつき、ネットワークタブは再フェッチの嵐で真っ赤に染まった。あの冷や汗混じりの夜を境に、俺の設計思想は根本からひっくり返った。
「Server ActionsがあればReact Queryは不要」——そんな甘い幻想を抱いている君に、俺が3時間かけて泥沼にハマった理由と、今の最強の「棲み分け」を叩き込んでやる。
1. なぜ「Server Actions一本足打法」で地獄を見たのか
App Routerは確かに革命的だ。APIルートを立てる手間は消え、型安全にサーバーと会話できる。だが、Server Actionsはあくまで「サーバーへのリクエスト手段」であって、「クライアントの状態管理ツール」じゃない。
俺が犯した最大のミスは、`revalidatePath`を連打して無理やり画面を更新しようとしたことだ。これだと、リストの並び替え一つでページ全体の再フェッチが走り、UXは台無しになる。
| 機能 | Server Actions (単体) | React Query 併用 |
|---|---|---|
| データ取得 | ページ全体のリロードが必要 | キャッシュされたデータのみ更新 |
| UI反映 | サーバー通信待ちでガタつく | `onMutate`で即時反映(楽観的UI) |
| 状態管理 | サーバー任せ(不安定) | クライアント側で一元管理 |
| 使い所 | 単純なフォーム送信 | 動的なダッシュボード・リスト |
2. React Queryを再導入して分かった「境界線」
結局、アプリの体験(UX)を決定づけるのは、クライアント側でどうデータをキャッシュし、どう即時反映させるかだ。俺は現在、以下のハイブリッド構成に落ち着いている。
// クライアント側: React Queryで「体験」を支配する
'use client'
import { useMutation, useQueryClient } from '@tanstack/react-query'
function TaskItem({ task }) {
const queryClient = useQueryClient();
const { mutate } = useMutation({
mutationFn: (data) => updateTask(task.id, data),
// サーバーの返答を待たずにUIを書き換える「魔法」
onMutate: async (newData) => {
await queryClient.cancelQueries({ queryKey: ['tasks'] });
const previousTasks = queryClient.getQueryData(['tasks']);
queryClient.setQueryData(['tasks'], (old) =>
old.map(t => t.id === task.id ? { ...t, ...newData } : t)
);
return { previousTasks };
},
onError: (err, newData, context) => {
// 失敗したらロールバックして元に戻す(これが安心感を生む)
queryClient.setQueryData(['tasks'], context.previousTasks);
}
});
return <button onClick={() => mutate({ status: 'DONE' })}>完了</button>;
}
この構成なら、通信中にUIが固まることはない。サーバーが死んでいようが、ユーザーには「完了した」ように見える。これが現代のWebアプリのスタンダードだ。
3. ハイドレーションエラーという「3時間の罰ゲーム」
ここで一つ、俺が3時間溶かした罠を共有しておく。Server Componentsで取得したデータをReact Queryの初期値として渡す時、`dehydrate`を使わないとハイドレーションエラー地獄に突入する。
サーバーとクライアントでレンダリング結果が微妙にズレる。コンソールには `Text content does not match server-rendered HTML` の無慈悲なメッセージ。
解決策はこれだ:
// サーバー側で取得して、クライアントへ「持ち込む」
export default async function Page() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({ queryKey: ['tasks'], queryFn: fetchTasks });
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<TaskList />
</HydrationBoundary>
);
}
サーバーでフェッチしたデータを`dehydrate`でシリアライズし、クライアントに渡す。これを怠ると、Next.jsは「サーバーとクライアントでデータが違うぞ!」とパニックを起こす。この一手間を惜しんだ代償が、あの3時間だ。
4. 結論:俺たちの設計ルール
意地を張って「全部Server Actionsでやる」なんて考えるな。自転車で高速道路を走ろうとするようなもんだ。以下のルールを叩き込め。
1. 初期ロード:Server Componentsで取得し、`HydrationBoundary`でクライアントへ流し込め。
2. 頻繁な更新:React Queryの`useMutation`の裏側でServer Actionsを叩け。
3. 単純なフォーム送信:React Queryすら不要。`useActionState`(旧 `useFormState`)で十分だ。
Server Actionsは「サーバーへの橋渡し」、React Queryは「体験のためのキャッシュ管理」。
この境界線を守るだけで、アプリは別物のようにヌルヌル動くようになる。Next.jsの進化は凄まじいが、React Queryが培ってきた「キャッシュと再試行」の知見は、今なお最強の武器だ。
さあ、変な意地は捨てて、React Queryと仲良くしようぜ。それが一番の近道だ。