🚛

Next.js App RouterでtRPC + TanStack Queryのprefetch&Hydration

に公開

Next.js 15のApp RouterとtRPC、TanStack Queryを組み合わせた際のサーバーサイドprefetchについて備忘録として実装した内容を踏まえて構成の書き出しをおこないました。

技術スタック

  • Next.js 15 (App Router)
  • tRPC
  • TanStack Query
  • TypeScript
  • Drizzle ORM

全体のアーキテクチャ

実装について

1. サーバーサイドでのprefetch

src/app/(dashboard)/agents/page.tsx
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
import { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';

import {
  AgentsView,
  AgentsViewError,
  AgentsViewLoading,
} from '@/modules/agents/ui/view/agents-view';
import { getQueryClient, trpc } from '@/trpc/server';

const AgentsPage = () => {
  const queryClient = getQueryClient();
  // サーバーサイドでデータをprefetch
  void queryClient.prefetchQuery(trpc.agents.getMany.queryOptions());

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <Suspense fallback={<AgentsViewLoading />}>
        <ErrorBoundary fallback={<AgentsViewError />}>
          <AgentsView />
        </ErrorBoundary>
      </Suspense>
    </HydrationBoundary>
  );
};

export default AgentsPage;

2. tRPCサーバー設定

src/trpc/server.tsx
import { createTRPCOptionsProxy } from '@trpc/tanstack-react-query';
import { cache } from 'react';
import 'server-only';

import { createTRPCContext } from './init';
import { makeQueryClient } from './query-client';
import { appRouter } from './routers/_app';

// 同一リクエスト内で同じクライアントを返す
export const getQueryClient = cache(makeQueryClient);

export const trpc = createTRPCOptionsProxy({
  ctx: createTRPCContext,
  router: appRouter,
  queryClient: getQueryClient,
});

export const caller = appRouter.createCaller(createTRPCContext);

3. QueryClient設定

src/trpc/query-client.ts
import {
  defaultShouldDehydrateQuery,
  QueryClient,
} from '@tanstack/react-query';

export function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 30 * 1000,
      },
      dehydrate: {
        shouldDehydrateQuery: (query) =>
          defaultShouldDehydrateQuery(query) ||
          query.state.status === 'pending',
      },
      hydrate: {
        // カスタムシリアライザーを使用する場合
        // deserializeData: superjson.deserialize,
      },
    },
  });
}

4. クライアントサイドでの利用

src/modules/agents/ui/view/agents-view.tsx
'use client';

import { keepPreviousData, useSuspenseQuery } from '@tanstack/react-query';
import { useTRPC } from '@/trpc/client';

export const AgentsView = () => {
  const trpc = useTRPC();

  // prefetchされたデータがhydrateされて即座に利用可能
  const { data } = useSuspenseQuery(
    trpc.agents.getMany.queryOptions(undefined, {
      placeholderData: keepPreviousData,
    })
  );

  return <div>{JSON.stringify(data, null, 2)}</div>;
};

重要なポイント解説

1. cache()の役割

export const getQueryClient = cache(makeQueryClient);

なぜ必要?

  • 同一リクエスト内で同じQueryClientインスタンスを使用
  • React 18のconcurrent featuresでの重複実行を防止
  • メモリ効率の向上

2. dehydrate/hydrateプロセス

// サーバーサイド: データをシリアライズ
<HydrationBoundary state={dehydrate(queryClient)}>

// クライアントサイド: データを復元
const { data } = useSuspenseQuery(...)

プロセス詳細

  1. サーバーでクエリを実行
  2. dehydrate()でクエリキャッシュをシリアライズ
  3. HTMLと共にクライアントに送信
  4. クライアントでQueryClientにhydrate
  5. useSuspenseQueryで即座にデータ利用可能

3. shouldDehydrateQueryの設定

shouldDehydrateQuery: (query) =>
  defaultShouldDehydrateQuery(query) ||
  query.state.status === 'pending',

重要な理由

  • pending状態のクエリもdehydrate対象に含める
  • サーバーレンダリング中に完了しなかったクエリもクライアントで継続

4. useSuspenseQueryの利点

const { data } = useSuspenseQuery(...)

従来のuseQueryとの違い

  • dataが常に定義済み(TypeScript的に安全)
  • ローディング状態はSuspenseで処理
  • エラー状態はErrorBoundaryで処理

パフォーマンス最適化のポイント

1. staleTimeの設定

queries: {
  staleTime: 30 * 1000, // 30秒間はfreshとみなす
}

2. placeholderDataでUX向上

useSuspenseQuery(
  trpc.agents.getMany.queryOptions(undefined, {
    placeholderData: keepPreviousData, // 前のデータを表示
  })
);

3. サーバー・クライアント分離

// サーバー用QueryClient
export const getQueryClient = cache(makeQueryClient);

// ブラウザ用QueryClient(シングルトン)
if (!browserQueryClient) browserQueryClient = makeQueryClient();

実際の動作フロー

  1. サーバーサイド:
void queryClient.prefetchQuery(trpc.agents.getMany.queryOptions());

prefetchQueryはPromiseを返すが、ここでは戻り値を使わないのでvoidでPromiseの戻り値を明示的に無視

  1. データ取得:
// agents procedureが実行される
export const agentsRouter = createTRPCRouter({
 getMany: baseProcedure.query(async () => {
   const data = await db.select().from(agents);
   return data;
 }),
});
  1. Hydration:
<HydrationBoundary state={dehydrate(queryClient)}>
  1. クライアント利用:
const { data } = useSuspenseQuery(...) // 即座にデータ利用可能

まとめ

この仕組みにより実現できること

  • Zero Loading Time: 初期表示でローディング不要
  • Type Safety: tRPCによる型安全性
  • Optimal UX: SuspenseとErrorBoundaryによる宣言的UI
  • Performance: サーバーサイドprefetchによる高速表示

Next.js App RouterとtRPC、TanStack Queryの組み合わせは、モダンなWebアプリケーションに必要なパフォーマンスとDXを両立する組み合わせです。特にprefetch+hydrationパターンを理解することでユーザー体験を大幅に改善できます。

感想

tRPCの公式を参考にしつつtanstack queryを使ったprefetchの実装をおこないました。
リクエスト&レンダリングパフォーマンスは上がると思いますがNext.jsでORMを利用しServer Componentから情報を取得するのであれば無理に使わなくても(tanstack queryもtRPCも)良いなとも思ったりもしました。

色々な型安全の手法はありますがtRPCも例に漏れず型安全性が高いので便利という印象です。
monorepoでのTS採用の場合の選択肢としては小〜中規模プロジェクトにとっては結構アリかなと思いました。

参考リンク

Discussion