Server ComponentとTanStack Query、結局どっちでfetchするか

App RouterでデータフェッチをServer Componentに寄せる構成とTanStack Queryを併用しているんですけど、この境界線を曖昧にしたまま進めていたら、最近その影響がコードに出てきちゃいました。現状のコードベースで何が起きてるのか、整理も兼ねて書き残しておこうと思います。

初期表示はServer Componentで取るのが楽という前提

これは揺るがないと思います。ページのルートやレイアウトに近いコンポーネントで直接 fetchawait して、取れたデータを子のClient Componentにpropsで流し込むのが一番シンプルです。クライアント側で useEffect の発火を待たなくていいし、マウント直後のローディング表示も不要。バンドルサイズ的にも、データ取得のためのユーティリティや状態管理のコードをクライアントに含めずに済むのは大きいです。

特にダッシュボード系の初期表示や、記事詳細みたいにSEOが絡むページなら、今のところこの構成以外を選ぶ理由はないと思います。

問題は、そのデータをクライアント側の操作と連動させようとし始めたときです。

initialDataに突っ込めば済むと思ってた

最初は単純に、Server Componentで取得したデータをClient Componentの useQueryinitialData として渡せば、キャッシュとサーバーフェッチのいいとこ取りができるだろうと考えていました。

// Server Component
async function TodoListPage() {
  const todos = await fetchTodos();
  return <TodoList initialTodos={todos} />;
}

// Client Component
function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
  const { data } = useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
    initialData: initialTodos,
  });
  // ...
}

理屈は合ってるはずです。初回描画はサーバー側で完結してウォーターフォールも起きない。クライアント側ではキャッシュとして扱われるので、マウント直後の即時再フェッチも走りません。staleTime を明示的に Infinity あたりにしておけば、しばらくはこのデータを信頼してくれます。

ただ、Prismaからそのまま引っこ抜いたモデルオブジェクトを渡すケースでは、Dateオブジェクトがそのままpropsに乗ってしまい、ハイドレーションエラーを踏みました。Prismaの createdAtupdatedAtDate 型ですが、サーバーからクライアントへの受け渡しはシリアライズされる前提なので、このままだと通らないんですよね。

対策として、取得したデータを一度DTOに変換するレイヤーを挟むことになります。

function toTodoDTO(todo: Todo): TodoDTO {
  return {
    ...todo,
    createdAt: todo.createdAt.toISOString(),
    updatedAt: todo.updatedAt.toISOString(),
  };
}

これをサーバー側のfetch関数の中に組み込むか、取得後にmapで回すか。最初は「これくらいなら大したことない」と思ってたんですが、モデルの数が増えてくると地味にコード量がかさみます。しかも、DTOを挟むと今度はクライアント側で日付操作をするときに new Date(dto.createdAt) と戻す手間も出てくる。

結局、サーバーからクライアントに渡すデータと、クライアントが自力でフェッチするデータの型が別物になるケースが出てきて、型定義の同期がずれ始めちゃいました。

placeholderDataにすると二重フェッチが走る

initialData の代替として placeholderData を検討したこともあります。でも、これは挙動が全く違うんですよね。placeholderData はあくまでプレースホルダーなので、クライアント側がマウントした瞬間に必ず実データを取りに行きます。

const { data, isPlaceholderData } = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  placeholderData: keepPreviousData,
});

サーバーでせっかくフェッチしたデータが「仮の表示」としてしか扱われず、マウント直後に結局同じエンドポイントを叩くことになります。これじゃあServer Componentで初期表示を最適化した意味がほぼ失われちゃいます。placeholderData はページネーションやフィルタリングで既存データを表示しつつ新しいデータを裏で引く、といった用途には合っていますが、サーバーからの初期データ受け渡しには使えないですね。

キャッシュの二重管理が想像以上に厄介

深刻なのが、Next.js側のキャッシュとTanStack Queryのキャッシュが完全に分離している点です。

たとえばServer Actions経由でデータを更新する場合を考えてみます。revalidatePath('/todos') を呼べばNext.jsのData Cacheは破棄できて、次のサーバーリクエストでは最新のデータが取得されます。でも、クライアント側でTanStack Queryが queryKey: ['todos'] の古いキャッシュを握りしめたままだと、画面遷移を挟まない文脈では表示が古いままになっちゃうんです。

かといって、Server Actionsの成功時に毎回 queryClient.invalidateQueries({ queryKey: ['todos'] }) を呼ぶようにすると、今度はクライアント側のキャッシュが無効化されてマウント直後に再フェッチが走ります。サーバー側の revalidatePath でキャッシュを破棄しても、そのあとにTanStack Queryがリフェッチして取得するデータは、当然ながらサーバー側のキャッシュが破棄された後の最新のものになります。データの整合性は取れますが、リクエストが冗長になっちゃうんですよね。

さらに、revalidatePath ではクライアント側のキャッシュは一切関与しないため、ミューテーション後にサーバーから返された新しいデータと、クライアントのキャッシュの間に一時的な不整合が生じます。楽観的更新を入れて失敗した場合のロールバックなど、複雑な状態更新が絡むと、サーバーの真の状態とクライアントの表示が乖離するタイミングがどこで発生しているのか、追うのがかなり難しくなる。

実際に、ポーリングを入れたチャット画面の実装でこの問題に直面しました。Server Actionsでメッセージを送信して、クライアント側はTanStack Queryの refetchInterval でポーリングしつつ、送信直後は楽観的更新でメッセージを追加する。送信に失敗したときのロールバック処理が、サーバーの revalidatePath とTanStack Queryのキャッシュ無効化のタイミングが噛み合わず、一瞬古いメッセージが復活するという現象を踏みました。

現時点での線引き

試行錯誤の末、いまは以下のルールで落ち着いています。

  1. ページの初期表示、SEOが必須の静的・動的コンテンツはServer Componentのfetch。 ここは揺るがないです。
  2. ユーザーの明示的な操作に強く依存するインタラクティブなUI(モーダル内の非同期取得、無限スクロール、リアルタイムポーリング)はTanStack Query。 Server Component単体では対応できないので。

問題は、この線引きでは対処しきれない「後からインタラクティブ性が入ってくる」パターン。最初は静的な一覧表示だったのに、あとから検索やフィルタリングが追加されて、気づいたらTanStack Queryに寄せないと厳しくなっている。かといって、最初からすべてTanStack Queryで実装すると、初期表示のオーバーヘッドが気になってしまいそう。

現在のコードベースには、同じエンドポイントを叩くラッパー関数がサーバー用とクライアント用で乱立しています。サーバー側は fetch を直接 await する素朴な関数、クライアント側は useQuery から呼ばれることを前提にした関数。型定義は一応共通のDTOを使っていますが、サーバー側でしか使わないフィールドや、クライアント側でしか不要なフィールドが混ざり始めて、だんだん同期が取れなくなっている感じです。

キャッシュの二重管理が残ってる

Next.jsのApp Routerを採用する以上、サーバー側のキャッシュとTanStack Queryのキャッシュはどちらも避けられません。この二つをどう整合させるのか、現状では場当たり的な対応しかできていません。Server Actionsのスコープ内でクライアントの状態をどこまで同期させるべきかも、まだ方針が固まっていないです。

いまはミューテーション後はサーバー側で revalidatePath、クライアント側で invalidateQueriesという二段構えで動かしていますが、このまま機能が増えていくと、サーバー側のキャッシュを破棄したシグナルをクライアント側に伝える仕組みを自前で用意しない限り、どのレイヤーが古いデータを掴んでいるのか追えなくなりそう。

致命的なバグは出てませんが、このまま機能を追加すると、古いデータを掴んでいるレイヤーを特定するのに時間がかかりそうです。もう少しコードベースが固まってきたら、この境界線のルール化をもう一度やり直す必要がありそうです。