💧

T3 AppのtRPC周りの構造を読み解いてみる

2024/10/06に公開

最近のNext.jsを中心としたパラダイムにキャッチアップするために、T3 App(create-t3-app)のコードを読んで学習を進めているが、tRPC周りの構造がリファレンスには詳述されておらず、所謂わからないところがわからない状態になってしまった。

そもそも、React Server Component、Next.js(App Router)、TanStack Queryについての知識が足りていないのもあったので、この機会にそれらの観点も調べつつT3 AppのtRPC周りの実装背景について一定の解像度を得られるように整理をしてみた。

https://create.t3.gg/en/folder-structure-app?packages=nextauth%2Cprisma%2Ctailwind%2Ctrpc

前提(バージョン)

  • @tanstack/react-query: 5.59.0
  • @trpc/client: 11.0.0-rc.553
  • @trpc/react-query: 11.0.0-rc.553
  • @trpc/server: 11.0.0-rc.553
  • next: 14.2.14
  • ct3aMetadata: 7.37.0

概要図

初見では割と複雑な依存関係に感じられたので、tRPC関連の実装モジュール間のimportの矢印を簡単に図示しつつ、厳密性には欠けるかもしれないが、BackendClient ComponentsServer Componentsの3つの領域に分類してみた。

読み解き方

概要図に示したBackendClient ComponentsServer Componentsの3つの領域を切り口として構造を追う。適宜、深掘りしたいモジュールについて疑問点を整理する。

事前知識

以下のtRPCとTanStack Queryのリファレンスに実装の原型を見ることができたので参考にしていく。

tRPC

https://trpc.io/docs/client/react/server-components

TanStack Query

https://tanstack.com/query/latest/docs/framework/react/guides/advanced-ssr

Backendの構造

この領域に関してはtRPCのBackend Usageのリファレンスからrouterprocedureでリソース定義を組み上げるイメージは掴めたので補記に留める。

src/server/api/root.tsにリソース定義を集約してexportすることでClient Components、Server ComponentsそれぞれのI/Fとの接続を図っている。

src/server/api/root.ts
import { postRouter } from "~/server/api/routers/post";
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";

/**
 * This is the primary router for your server.
 *
 * All routers added in /api/routers should be manually added here.
 */
export const appRouter = createTRPCRouter({
  post: postRouter,
});

// export type definition of API
export type AppRouter = typeof appRouter;

/**
 * Create a server-side caller for the tRPC API.
 * @example
 * const trpc = createCaller(createContext);
 * const res = await trpc.post.all();
 *       ^? Post[]
 */
export const createCaller = createCallerFactory(appRouter);

Client ComponentsとのI/Fはsrc/app/api/trpc/[trpc]/route.tsにてNext.js App RouterのRoute HandlerとtRPCのアダプター(@trpc/server/adapters/fetch)を介することでAPIとして実現している。

src/app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { type NextRequest } from "next/server";

import { env } from "~/env";
import { appRouter } from "~/server/api/root";
import { createTRPCContext } from "~/server/api/trpc";

/**
 * This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when
 * handling a HTTP request (e.g. when you make requests from Client Components).
 */
const createContext = async (req: NextRequest) => {
  return createTRPCContext({
    headers: req.headers,
  });
};

const handler = (req: NextRequest) =>
  fetchRequestHandler({
    endpoint: "/api/trpc",
    req,
    router: appRouter,
    createContext: () => createContext(req),
    onError:
      env.NODE_ENV === "development"
        ? ({ path, error }) => {
            console.error(
              `❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`
            );
          }
        : undefined,
  });

export { handler as GET, handler as POST };

Server ComponentsとのI/FはtRPCのServer Side Callsに記載されている通りcallerとして実現している。呼び出しの詳細はServer Componentsの構造で掘り下げたい。

Client Componentsの構造

tRPCのQuickstartSet up the React Query Integrationに記載されている流れと同様にLatestPost(src/app/_components/post.tsx)コンポーネントから前述のNext.js App RouterのRoute Handlerに載ったエンドポイント(src/app/api/trpc/[trpc]/route.ts)にtRPCクライアントでリクエストを飛ばすイメージになるかと思う。

src/trpc/react.tsx

reactというモジュールファイル名からだと直感的でない気もするが、T3 AppのFolder Structure (App)より責務としてはtRPC + TanStack Queryのクライアントを初期化してClient Componentsから利用できる状態にすることにあると思われる。

The react.tsx file is the front-end entrypoint to tRPC. It also contains utility types for the router inputs and outputs. See tRPC usage for more information.

src/trpc/react.tsx
"use client";

import { QueryClientProvider, type QueryClient } from "@tanstack/react-query";
import { loggerLink, unstable_httpBatchStreamLink } from "@trpc/client";
import { createTRPCReact } from "@trpc/react-query";
import { type inferRouterInputs, type inferRouterOutputs } from "@trpc/server";
import { useState } from "react";
import SuperJSON from "superjson";

import { type AppRouter } from "~/server/api/root";
import { createQueryClient } from "./query-client";

let clientQueryClientSingleton: QueryClient | undefined = undefined;
const getQueryClient = () => {
  if (typeof window === "undefined") {
    // Server: always make a new query client
    return createQueryClient();
  }
  // Browser: use singleton pattern to keep the same query client
  return (clientQueryClientSingleton ??= createQueryClient());
};

export const api = createTRPCReact<AppRouter>();

...

export function TRPCReactProvider(props: { children: React.ReactNode }) {
  const queryClient = getQueryClient();

  const [trpcClient] = useState(() =>
    api.createClient({
      links: [
        loggerLink({
          enabled: (op) =>
            process.env.NODE_ENV === "development" ||
            (op.direction === "down" && op.result instanceof Error),
        }),
        unstable_httpBatchStreamLink({
          transformer: SuperJSON,
          url: getBaseUrl() + "/api/trpc",
          headers: () => {
            const headers = new Headers();
            headers.set("x-trpc-source", "nextjs-react");
            return headers;
          },
        }),
      ],
    })
  );

  return (
    <QueryClientProvider client={queryClient}>
      <api.Provider client={trpcClient} queryClient={queryClient}>
        {props.children}
      </api.Provider>
    </QueryClientProvider>
  );
}

function getBaseUrl() {
  if (typeof window !== "undefined") return window.location.origin;
  if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
  return `http://localhost:${process.env.PORT ?? 3000}`;
}

Client Componentなのになぜサーバー実行を意識するのか?

先頭でuse client宣言をしたClient ComponentであるのにgetQueryClient()関数でサーバー実行を考慮している理由がわからなかったが、Next.jsのリファレンスを見るとClient ComponentもSSRされるとの記載があった。

https://nextjs.org/docs/app/building-your-application/rendering/client-components#full-page-load

To optimize the initial page load, Next.js will use React's APIs to render a static HTML preview on the server for both Client and Server Components. This means, when the user first visits your application, they will see the content of the page immediately, without having to wait for the client to download, parse, and execute the Client Component JavaScript bundle.

On the server:

  1. React renders Server Components into a special data format called the React Server Component Payload (RSC Payload), which includes references to Client Components.
  2. Next.js uses the RSC Payload and Client Component JavaScript instructions to render HTML for the route on the server.

実際にtrpcClientを初期化しているuseStateHook内にconsole.logを仕込んだところ、サーバー側のコンソールにもログ出力されたことから確からしいことがわかった。

use client宣言せずにブラウザ専用のHookやAPIを使用するとエラーになるという知識から、Client Componentにするとエラー回避のためにサーバー実行されないという先入観を持ってしまっていたが、実態は異なっていたことがわかった。

次に、なぜサーバー側とブラウザ側で分岐を設けているか気になった。getQueryClient()関数内部で呼んでいるcreateQueryClient()関数は常に新しいオブジェクトを返す実装となっているが、ブラウザ側の分岐ではシングルトンパターンを適用している。

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

export const createQueryClient = () =>
  new QueryClient({
    ...
  });

これは原型と思われるTanStack Queryのサンプルコードのコメントに詳細な背景が記載されていた。
https://tanstack.com/query/latest/docs/framework/react/guides/advanced-ssr#experimental-streaming-without-prefetching-in-nextjs

Browser: make a new query client if we don't already have one. This is very important, so we don't re-make a new client if React suspends during the initial render. This may not be needed if we have a suspense boundary BELOW the creation of the query client.

ブラウザ側での初期レンダリング時に(影響を受けない兄弟・子階層以外=親階層で)Suspenseが発生して中断状態から再レンダリングでもう一度関数実行された場合にQueryClientを再作成するのではなく、モジュールコンテキストに置いておけば状態保持されるというメカニズムを期待していると読み取れた。

これは初期レンダリング時に、後述するServer Componentのprefetchで取得したデータを引き継いだ状態で作成されるQuery Clientを保持するための考慮であると考えられる。

まとめると、Query Clientの初期化経路についてサーバー実行時のClient ComponentのSSRとブラウザ実行時のprefetchの状態引き継ぎの2つの観点が必要がであることがわかった。(実行順序の観点ではSSRの時点でサーバー側でもprefetchの伝搬が機能しているように見えるが、厳密には抑えきれていない。ここでは初期化経路の想定に留める)

QueryClientProviderとapi.ProviderでなぜProviderを二重定義しているのか?

これまでTanStackQueryを使ったことがなくその概念を知らなかったので、ClientのProviderをなぜ二重に定義しているのか初見では疑問に感じた。(xProviderはuseContextの文脈と推測)

ただ、TanStack Queryがサーバとの通信を行うクライアント実装の詳細を持たず、状態管理のラッパーであるという概念を知ると、QueryClientProviderがTanStack Queryの基本形でapi.Provider(trpcClient)がクライアント実装の詳細を担うというそれぞれの責務の輪郭を掴むことができた。

api.ProviderにもqueryClientを渡しているのは、TanStack Queryのリクエスト処理にあたるuseQueryuseMutationtrpcClientを内部的に接続するためであると推測している。

https://tanstack.com/query/latest/docs/framework/react/quick-start

Server Componentsの構造

tRPCのServer Side Callsに記載されている流れと同様にHome(src/app/page.tsx)コンポーネントからBackendの構造で言及したcaller(src/trpc/server.ts)で直接サーバー処理を呼び出すイメージになるかと思う。

src/trpc/server.ts

T3 AppのFolder Structure (App)より責務としてはClient Componentsのsrc/trpc/react.tsxと対になる形で、サーバー処理の呼び出しクライアントを提供することにあると思われる。

The server.ts file is the entrypoint for using tRPC in Server Components.

src/trpc/server.ts
import "server-only";

import { createHydrationHelpers } from "@trpc/react-query/rsc";
import { headers } from "next/headers";
import { cache } from "react";

import { createCaller, type AppRouter } from "~/server/api/root";
import { createTRPCContext } from "~/server/api/trpc";
import { createQueryClient } from "./query-client";

/**
 * This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when
 * handling a tRPC call from a React Server Component.
 */
const createContext = cache(() => {
  const heads = new Headers(headers());
  heads.set("x-trpc-source", "rsc");

  return createTRPCContext({
    headers: heads,
  });
});

const getQueryClient = cache(createQueryClient);
const caller = createCaller(createContext);

export const { trpc: api, HydrateClient } = createHydrationHelpers<AppRouter>(
  caller,
  getQueryClient
);

サーバー処理にcacheを使用して大丈夫なのか?

前述のsrc/trpc/react.tsxQueryClientではサーバー実行の場合は常に新しいオブジェクトを生成していたので、cacheがRequest Scopeを考慮しているのか気になった。tRPCのCreate a tRPC caller for Server Componentsのコードコメントに以下の記載があるため、問題なさそうではある。

// IMPORTANT: Create a stable getter for the query client that
// will return the same client during the same request.

Next.jsのリファレンスではcacheについてRequest Scopeと明記している箇所が見つけられなかったが、Reactのリファレンスを見ると以下のようにあるのでおそらく仕様的な根拠はここになるのではないかと思われる。

React will invalidate the cache for all memoized functions for each server request.

https://react.dev/reference/react/cache#caveats

HydrateClientとは何か?

tRPCのServer Side Callsではシンプルにcallerをそのまま使用しているのに、なぜcreateHydrationHelpers関数でラップしているのか、特にHydrateClientとは何か気になった。

tRPCのリファレンスにはHydrateClientに関する仕様の記載がなかったが、TanStack QueryのPrefetching and de/hydrating dataに手がかりを見つけることができたので、以下にそのサンプルコードを示す。

おおよその理解ではあるが、prefetchによるパフォーマンス向上をモチベーションとして、サーバー実行でprefetchを行ったQueryClientをdehydrate(シリアライズ)してClient ComponentであるHydrationBoundaryに埋め込むと、ブラウザ実行に移りhydrate(デシリアライズ)でQueryClientが初期化される際に、サーバー実行で取得していたデータが引き継がれるという仕組みのようである。

app/posts/page.jsx
import {
  dehydrate,
  HydrationBoundary,
  QueryClient,
} from '@tanstack/react-query'
import Posts from './posts'

export default async function PostsPage() {
  const queryClient = new QueryClient()

  await queryClient.prefetchQuery({
    queryKey: ['posts'],
    queryFn: getPosts,
  })

  return (
    // Neat! Serialization is now as easy as passing props.
    // HydrationBoundary is a Client Component, so hydration will happen there.
    <HydrationBoundary state={dehydrate(queryClient)}>
      <Posts />
    </HydrationBoundary>
  )
}
app/posts/posts.jsx
'use client'

export default function Posts() {
  // This useQuery could just as well happen in some deeper
  // child to <Posts>, data will be available immediately either way
  const { data } = useQuery({
    queryKey: ['posts'],
    queryFn: () => getPosts(),
  })

  // This query was not prefetched on the server and will not start
  // fetching until on the client, both patterns are fine to mix.
  const { data: commentsData } = useQuery({
    queryKey: ['posts-comments'],
    queryFn: getComments,
  })

  // ...
}

tRPCの観点に戻りHydrateClientの実装を確認すると、内部でHydrationBoundaryを利用しており、暗黙的に上記の狙いが意図されていることが確かめられた。T3 Appにおけるprefetchの流れについてはsrc/app/page.tsxで整理する。

node_modules/@trpc/react-query/src/rsc.tsx
...
  function HydrateClient(props: { children: React.ReactNode }) {
    const dehydratedState = dehydrate(getQueryClient());

    return (
      <HydrationBoundary state={dehydratedState}>
        {props.children}
      </HydrationBoundary>
    );
  }
...

src/app/page.tsx

Next.jsのRoot SegmentのPage(ホーム画面)にあたる。T3 AppのFolder Structure (App)には以下の記載がある。

The page.tsx file at the root directory of /app is the homepage of the application.

src/app/page.tsx
import Link from "next/link";

import { LatestPost } from "~/app/_components/post";
import { getServerAuthSession } from "~/server/auth";
import { api, HydrateClient } from "~/trpc/server";

export default async function Home() {
  const hello = await api.post.hello({ text: "from tRPC" });
  const session = await getServerAuthSession();

  void api.post.getLatest.prefetch();

  return (
    <HydrateClient>
      <main className="flex min-h-screen flex-col items-center justify-center bg-gradient-to-b from-[#2e026d] to-[#15162c] text-white">
        <div className="container flex flex-col items-center justify-center gap-12 px-4 py-16">
        ...
          {session?.user && <LatestPost />}
        </div>
      </main>
    </HydrateClient>
  );
}

prefetchを非同期処理にしているのはなぜか?

void api.post.getLatest.prefetch()にフォーカスしたい。前述のHydrateClientの仕組みに基づき、post.getLatestリソースをprefetchすることで、子階層のClient ComponentであるLatestPost(src/app/_components/post.tsx)コンポーネントのapi.post.getLatest.useSuspenseQuery()の通信を省略し、レンダリングを高速化するモチベーションであることはわかるが、awaitでPromiseの解決を待たなくて良いのか気になった。

こちらもHydrate Clientと同様にTanStack QueryのStreaming with Server Componentsに手がかりを見つけることができた。

As of React Query v5.40.0, you don't have to await all prefetches for this to work, as pending Queries can also be dehydrated and sent to the client. This lets you kick off prefetches as early as possible without letting them block an entire Suspense boundary, and streams the data to the client as the query finishes. This can be useful for example if you want to prefetch some content that is only visible after some user interaction, or say if you want to await and render the first page of an infinite query, but start prefetching page 2 without blocking rendering.

To make this work, we have to instruct the queryClient to also dehydrate pending Queries. We can do this globally, or by passing that option directly to hydrate.

つまり、pending状態のPromiseもdehydrate & hydateが可能であり、src/trpc/query-client.tsquery.state.status === "pending"の指定をすることで、prefetchをトリガーしつつ、Promiseの解決を遅延させることができるということのようである。

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

export const createQueryClient = () =>
  new QueryClient({
    defaultOptions: {
      queries: {
        // With SSR, we usually want to set some default staleTime
        // above 0 to avoid refetching immediately on the client
        staleTime: 30 * 1000,
      },
      dehydrate: {
        serializeData: SuperJSON.serialize,
        shouldDehydrateQuery: (query) =>
          defaultShouldDehydrateQuery(query) ||
          query.state.status === "pending",
      },
      hydrate: {
        deserializeData: SuperJSON.deserialize,
      },
    },
  });

まとめ

ひと通り整理して見返してみるとprefetchをしたQueryClientをServerComponentからClientComponentにdehydrate & hydrateするという考え方がT3 Appのリファレンスに明記されていなかったのが、難点だったのかなと感じた。

糸口はそれなりに見つけられたと思うので、引き続きSSR時の実行順序などメンタルモデルの精度を上げていきたい。

Discussion