Next.js 14/15で「React Queryは不要」と叫んだあの日、俺のブラウザは死んだ

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と仲良くしようぜ。それが一番の近道だ。