🫥

【T3 Stack入門】tRPCの基礎 vol.2 (create-t3-appコード解説編)

2024/10/03に公開

はじめに

tRPCは、TypeScriptを使用したフルスタック開発において、クライアントとサーバー間の型安全なAPI通信を実現するライブラリです。

この記事では、T3 Stackのプロジェクト雛形であるcreate-t3-appで作成されたリポジトリのコードを読み解きながら、tRPCの基本概念について解説します。

tRPCのメリットについては他のAPI構築手法と比較した記事をvol.1で書きましたので、ご興味のある方はご覧ください。
https://zenn.dev/maicom/articles/060674d9cc39ca

なお、本記事のコード解説ではNext.jsのApp Routerを使用しますが、App Routerの基本的な機能の解説ついては省略させていただきます。App Routerの基本について知りたい方は以下の記事たちをお読みいただくと、本記事への理解が深まるかと思います。
https://zenn.dev/blueish/articles/4b2ae3781ade57
https://zenn.dev/blueish/articles/61526c0983362e

create-t3-appについて

create-t3-appは、Next.js、TypeScript、tRPCなどのT3 Stackの技術スタックを組み合わせたプロジェクト雛形です。インストール時に使いたいライブラリを選ぶだけで、T3 Stackを用いたアプリ開発ができる環境を提供してくれます。

create-t3-appをインストールすると、すでにtRPCがNext.jsに組み込まれており、サンプルProcedure(=APIのエンドポイント)を実行できる状態まで実装されています。サンプルProcedureに関連するコードを読み解くことでtRPCの基本構造を理解していきましょう。
https://create.t3.gg/

create-t3-appコマンドで雛形をインストールする手順

コマンド実行

下記コマンドをターミナルで実行してパッケージをインストールしてください。最新版(latest)のT3 Stackのプロジェクト雛形を使うことができます。

$ npm create t3-app@latest

ライブラリの選択

コマンドを実行するとプロジェクト名や使うライブラリの選択についての質問が表示されます。

本記事では、以下を選択したリポジトリを作成しました。以降のコード例ではこちらのリポジトリのコードを参照しています。

質問 選択した回答
What will your project be called? 任意のプロジェクト名
Will you be using Typescript or JavaScript? TypeScript
Will you be using Tailwind CSS for styling? Yes
Would you like to use tRPC? Yes
What authentication provider would you like to use? NextAuth.js
What database ORM would you like to use? Prisma
Would you like to use Next.js App Router? Yes
What database provider would you like to use? SQLite(LibSQL)
Should we initialize a Git repository and stage the changes? Yes
Should we run ‘npm install’ for you? Yes
What import alias would you like to use? ~/

ローカルサーバーの起動

インストールが完了したら、以下を入力して作成したプロジェクトのディレクトリに入ります。

$ cd *作成したプロジェクト名*

プロジェクトのルートディレクトリに入ったら、以下を入力します。このコマンドを実行すると、Prismaスキーマがデータベースと同期され、スキーマに基づいてPrisma Client用のTypeScript型が生成されます。

$ npm run db:push

認証プロバイダーの設定が完了したら、以下を入力してローカルサーバーを起動します。

$ npm run dev

http://localhost:3000/ にブラウザからアクセスし、「Create T3 App」と書かれた画面(Next.jsのルートページであるsrc/app/page.tsx )が表示されれば環境構築は完了です。

サンプルProcedureの確認

この雛形にはあらかじめサンプルProcedure(=APIのエンドポイント)を実行できる状態まで実装されています。どのようなProcedureが使われているかルートページ内を確認してみましょう。

まず、「Sign in」ボタンを押してDiscordのサインインを行います。

サインインが完了しルートページにリダイレクトされると、「Logged in as ユーザー名」と入力フォームが表示されます。

以下がサンプルProcedureの内容一覧と、画面内での実装箇所です。実際にフォームに入力してsubmitボタンを押すと、creategetLatest の動きがわかるかと思います。

今回はこれらのサンプルProcedureを呼び出すために、雛形でどのように設定されているか関連ファイルのコードを解説していきます。

画面に実装されているサンプルProcedure一覧

Procedure名 機能 画面上の動作
hello 簡単な挨拶(Hello from 設定した文字列)を返します。 ページにアクセスしたときに表示する
create 新しい投稿を作成します。 フォーム入力後、submitボタン押下時に投稿が実行される
getLatest 最新の投稿を取得します。 最新の投稿内容をフォーム上に表示する。まだ投稿が存在しない場合は「You have no posts yet.」を表示する

tRPCの基本用語と全体のフロー

具体的な実装を見ていく前に、まずtRPCの基本用語と全体の流れを把握しておきましょう。

tRPCの基本用語解説

Procedure(プロシージャ)
tRPCのAPIエンドポイントに対応する関数で、クライアントからのリクエストに対して、必要な処理(例:データベースからのデータ取得やデータ更新)を実行し、結果を返します。

Middleware(ミドルウェア)
Procedureの実行前後に挿入される処理です。リクエストやレスポンスの加工や認証、エラーハンドリングなどに使用されます。

Router(ルーター)
複数のProcedureをグループ化し、構造化するためのオブジェクトです。Routerは他のSub Routerをネストすることができます。これにより、複数のRouterを組み合わせて、大規模なAPIを整理して構築することが容易になります。

Adapter(アダプター)
tRPCサーバーとクライアント間の通信を橋渡しする役割を持ちます。クライアントからのリクエストを受け取ってProcedureに転送し、そのレスポンスをクライアントに返します。

Context(コンテキスト)
リクエストごとに生成される、Procedure内で利用可能なデータや機能を含むオブジェクトです。Procedure内で使用するデータやサービス(例: ユーザーのセッション情報、データベース接続)を提供します。

tPRCのフロー図

上記の構成要素が実行されるフローを図にしました。

このフローを念頭に置いた上で、create-t3-appで作られたリポジトリで実際のコードを確認していきましょう。

ディレクトリ構成

create-t3-appで生成されたプロジェクトの主要なディレクトリ構造は以下となります。

# tRPCに関連する部分を中心に抜粋
src
├── app # Next.js App Routerを使用したフロントエンド部分
│   ├── _components
│   │   └── post.tsx # 投稿関連のコンポーネント(ClientComponents)
│   ├── api
│   │   └── trpc
│   │       └── [trpc]
│   │           └── route.ts # tRPCのHTTPハンドラー(Next.jsのRoute Handlers)
│   ├── layout.tsx # アプリケーション全体のレイアウトコンポーネント
│   └── page.tsx # アプリケーションのルートページコンポーネント(ServerComponents)
├── server # バックエンド部分(tRPCサーバーサイドロジック)
│   └── api 
│      ├── root.ts # 全tRPCRouterを統合するRoot Router
│      ├── routers
│      │   └── post.ts # 投稿関連のtRPCProcedureを定義するSub Router
│      └── trpc.ts # tRPCの初期化と共通設定
└── trpc # tRPCのクライアント・サーバー共通設定とユーティリティ
    ├── query-client.ts # TanStack Queryのクライアント設定
    ├── react.tsx # クライアントサイドtRPC用Reactフックとプロバイダー
    └── server.ts # サーバーサイドtRPCクライアント初期化

Next.jsはサーバーサイドの処理も可能なフレームワークなので、バックエンドとフロントエンドのコードが同一のリポジトリ内に共存しています。src直下の3つのディレクトリの役割を大まかに分けると以下になります。

主要ディレクトリの役割

src/app: Next.js App Routerを使用したフロントエンド部分を担うディレクトリです。コンポーネントやページが配置されています。

src/server : tRPCのサーバーサイドのロジック(API構築やDB接続)が入ったバックエンド部分を担うディレクトリです。

src/trpc: フロントエンドとバックエンドの橋渡しとなる役割を担うディレクトリです。tRPCをフロントエンドのServer Components / Client Componentsで呼ぶための共通設定が行われています。

では、主要ディレクトリ内の各ファイルでどのような処理を行なっているか追っていきます。

src/server :tRPCの初期化、APIの作成

このsrc/server ディレクトリには以下3つのファイルが収められており、tRPCの初期化、APIの作成、ルーティング設定が行われています。

├── server # バックエンド部分(tRPCサーバーサイドロジック)
│   └── api 
│      ├── root.ts # 全tRPCRouterを統合するRoot Router
│      ├── routers
│      │   └── post.ts # 投稿関連のtRPCProcedureを定義するSub Router
│      └── trpc.ts # tRPCの初期化と共通設定

src/server/api/trpc.tsでのtRPC初期化

import { initTRPC } from "@trpc/server";
import superjson from "superjson";
import { ZodError } from "zod";

import { db } from "~/server/db";

// コンテキストの作成関数
export const createTRPCContext = async (opts: { headers: Headers }) => {
  return {
    db,
    ...opts,
  };
};

 // tRPCを初期化し、tオブジェクトを作成
const t = initTRPC.context<typeof createTRPCContext>().create({
  transformer: superjson,
  errorFormatter({ shape, error }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        zodError:
          error.cause instanceof ZodError ? error.cause.flatten() : null,
      },
    };
  },
});

// サーバーサイドの呼び出し元を作成する関数
export const createCallerFactory = t.createCallerFactory;

// tRPC APIで新しいルーターとサブルーターを作成する関数
export const createTRPCRouter = t.router;

// Procedureの実行時間を計測し、開発環境で人工的な遅延を追加するMiddleware
const timingMiddleware = t.middleware(async ({ next, path }) => {
  const start = Date.now();

  if (t._config.isDev) {
    // artificial delay in dev
    const waitMs = Math.floor(Math.random() * 400) + 100;
    await new Promise((resolve) => setTimeout(resolve, waitMs));
  }

  const result = await next();

  const end = Date.now();
  console.log(`[TRPC] ${path} took ${end - start}ms to execute`);

  return result;
});

// 認証が不要なProcedure
export const publicProcedure = t.procedure.use(timingMiddleware);

// 認証が必要なProcedure
export const protectedProcedure = t.procedure
  .use(timingMiddleware)
  .use(({ ctx, next }) => {
    if (!ctx.session || !ctx.session.user) {
      throw new TRPCError({ code: "UNAUTHORIZED" });
    }
    return next({
      ctx: {
        // infers the `session` as non-nullable
        session: { ...ctx.session, user: ctx.session.user },
      },
    });
  });

ここではtRPCのContextの作成、tRPCの初期化、RouterやProcedureの定義が行われます。

createTRPCContext 関数:
リクエスト処理時に利用可能な情報(セッション、データベース接続、ヘッダーなど)を提供します。後述のt オブジェクトを利用して、 protectedProcedure内でctx として参照されています。

tオブジェクトの作成:
tRPCの中核となるオブジェクトで、Router、Procedure、Middlewareの作成に使用されます。createTRPCContext の型を持っているため、tオブジェクトを通じて作成されるProcedureやミドルウェアは、このコンテキストにアクセスできます。
例えば、protectedProcedure では ctx.session を参照してユーザーの認証状態をチェックしています。

また、transfomerに設定されているsuperjson は、JavaScriptの複雑なデータ構造をJSON形式でシリアライズ/デシリアライズするためのライブラリです。標準のJSONでは扱えない型(Date, Map, Set, BigInt など)をサポートし、クライアントとサーバー間でこれらの複雑な型を安全にやり取りできます。

errorFormatterに設定されているZodError は、Zodライブラリによるバリデーションが失敗した際に発生するエラーの型です。ZodErrorには、どのフィールドでどのようなバリデーションエラーが発生したかの詳細情報が含まれており、クライアント側に具体的なエラー内容を伝えることができます。

ProcedureオブジェクトとMiddlewareの利用:
publicProcedureprotectedProcedure の2種類が定義されています。publicProcedure は認証不要、protectedProcedure は認証済みユーザー用です。

t.procedure.use(*ミドルウェアの処理*) でMiddlewareを入れることができます。このコードでは実行時間の計測と開発環境での人工的な遅延を追加するtimingMiddleware と、protectedProcedureでは認証チェックもMiddlewareとして実装されています。

これらのProcedureオブジェクトは、src/server/api/routers/post.tsのRouter定義時に使用されています。

src/server/api/routers/post.tsでのProcedure定義

import { z } from "zod";

import {
  createTRPCRouter,
  protectedProcedure,
  publicProcedure,
} from "~/server/api/trpc";

// postRouterの定義:投稿に関連する操作をまとめたSub Router
export const postRouter = createTRPCRouter({
  // 認証が不要なProcedure:誰でもアクセス可能
  hello: publicProcedure
    // 入力スキーマの定義:textという文字列を受け取る
    .input(z.object({ text: z.string() }))
    // クエリの実装:入力されたtextを使って挨拶メッセージを返す
    .query(({ input }) => {
      return {
        greeting: `Hello ${input.text}`,
      };
    }),

  // 認証が必要なProcedure:認証済みユーザーのみアクセス可能
  create: protectedProcedure
    // 入力スキーマの定義:nameという1文字以上の文字列を受け取る
    .input(z.object({ name: z.string().min(1) }))
    // ミューテーション(データ変更操作)の実装
    .mutation(async ({ ctx, input }) => {
      // データベースに新しい投稿を作成
      return ctx.db.post.create({
        data: {
          name: input.name,
          // 現在のユーザーを投稿の作成者として関連付け
          createdBy: { connect: { id: ctx.session.user.id } },
        },
      });
    }),

  // 認証が必要なProcedure:最新の投稿を取得
  getLatest: protectedProcedure.query(async ({ ctx }) => {
    // 現在のユーザーが作成した投稿の中で最新のものを取得
    const post = await ctx.db.post.findFirst({
      orderBy: { createdAt: "desc" },
      where: { createdBy: { id: ctx.session.user.id } },
    });

    // 投稿が見つかればその投稿を、見つからなければnullを返す
    return post ?? null;
  }),

  // 認証が必要なProcedure:秘密のメッセージを取得(画面には未実装)
  getSecretMessage: protectedProcedure.query(() => {
    // 認証済みユーザーのみがアクセスできる秘密のメッセージを返す
    return "you can now see this secret message!";
  }),
});

このファイルでは、postRouterという名前のRouterを作成し、その中に4つのProcedureを定義しています。

Procedure名 機能 認証不要or 認証必要
hello 簡単な挨拶(Hello from 設定した文字列)を返します。 publicProcedure(認証不要)
create 新しい投稿を作成します。 protectedProcedure(認証必要)
getLatest 最新の投稿を取得します。 protectedProcedure(認証必要)
getSecretMessage 認証済みユーザーのみがアクセスできる秘密のメッセージを返します。 protectedProcedure(認証必要)

input内の処理:
inputメソッドは、Zodを使用してProcedureの入力データの構造と型を定義します。例えばhello では、.input(z.object({ text: z.string() })) で、textフィールドにstring型の入力を指定しています。これにより、実行時の入力データの検証と、開発時の型安全性が確保されます。

queryとは:
データの読み取り操作を定義します。サーバーからデータを取得するために使用されています。

mutationとは:
データの書き込みや更新操作を定義します。サーバー側でデータを変更する際に使用され、例えば新しい投稿の作成やユーザー情報の更新などに適しています。

getLatestで使われているctxとは:
ctxは、現在のユーザーセッション情報やデータベース接続など、Procedure内で利用可能なContextのオブジェクトです。例えば、ctx.session.user.id で現在のユーザーIDにアクセスしたり、ctx.db でデータベース操作を行っています。

続いて、このファイルで作られたSub RouterであるPostRoutersrc/server/api/root.ts で一つのRouterに集約します。

src/server/api/root.tsでSub Routerをまとめる

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

// /api/routersディレクトリに定義された個別のルーター(この場合はpostRouter)をまとめる
export const appRouter = createTRPCRouter({
  post: postRouter,
  // 将来的に新しいSub Routerを追加する場合、ここに追加します
});

// APIの型定義をエクスポート:クライアント側での型安全性を確保
export type AppRouter = typeof appRouter;

// サーバーサイドでAPIを直接呼び出すため関数
export const createCaller = createCallerFactory(appRouter);

このファイルでは、各Sub Routerを1つのRouterにまとめます。

appRouter でSub Routerを集約:
/api/routersディレクトリに定義された個別のルーター(この場合はpostRouter)をまとめる役割を果たします。将来的に新しいルーターを追加する際は、ここに手動で追加する必要があります。

AppRouter型のエクスポート:
appRouterの型定義をエクスポートしています。これにより、クライアント側でAPIの型情報を利用できるようになり、型安全性が向上します。

GraphQLやOpenAPIでは別途スキーマファイルを作成する必要がありますが、このAppRouter型のインポートだけで型情報をクライアントと共有できるシンプルさが、tRPCの魅力のひとつです。

createCaller関数:
これを使用することで、サーバーサイドでAPIを直接呼び出すことができます。最終的にはServerComponentsでtrpc.post.all()のように、定義されたProcedureを呼び出すことが可能になります。このサーバーサイドで直接呼び出すための実装はsrc/trpc/server.ts にて行われています。

src/trpc: tRPCをフロントエンドで呼び出すための設定

ここまで、APIの実装とルーティングの流れを確認しました。次に、フロントエンドでtRPCのAPIを呼び出すための設定について、src/trpcディレクトリの各ファイルを見ていきます。

└── trpc # tRPCとTanstack Queryのクライアント・サーバー共通設定とユーティリティ
    ├── query-client.ts # TanStack Queryのクライアント設定
    ├── react.tsx # クライアントサイドtRPC用Reactフックとプロバイダー
    └── server.ts # サーバーサイドtRPCクライアント初期化                       

このsrc/trpcディレクトリではサーバーサイド(ServerComponents)とクライアントサイド(ClientComponents)でtRPCのAPIを呼び出すための設定ファイルが入っています。

tRPCで実行されたAPIのキャッシュやデータ管理を、Tanstack Query(データフェッチング、キャッシングを効率的に管理するためのライブラリ)が担っています。

具体的なコードを見ていく前に、ServerComponentsとClientsComponentsのtRPCのAPI呼び出し方法の違いについて説明します。

ServerComponentsとClientsComponentsのAPI実行方法の違い

ServerComponents:
サーバー上で実行されるため、tRPC関数を直接呼び出すことができます。データのfetchやprefetchを行うときは、Tanstack Queryを利用してキャッシュしたデータをClientComponentsに受け渡します。

ClientComponents:
ブラウザで実行されるため、HTTPリクエストを介してAPIエンドポイントを呼び出します。これらのリクエストは、Next.jsのAPIルートを通じて処理されます。Tanstack Queryを使用してデータのフェッチング、キャッシング、および状態管理を行っています。

この違いを頭の片隅に置いた上で、各ファイルの中身を読み進めていきましょう。

src/trpc/query-client.tsでTanStack QueryのQueryClientを設定

import {
  defaultShouldDehydrateQuery,
  QueryClient,
} from "@tanstack/react-query";
import SuperJSON from "superjson";

// QueryClientを作成する関数
export const createQueryClient = () =>
  new QueryClient({
    defaultOptions: {
      queries: {
        // データを再フェッチするまでの時間を設定
        staleTime: 30 * 1000
      },
      // サーバーからクライアントへのデータ転送(デハイドレーション)の設定
      dehydrate: {
        serializeData: SuperJSON.serialize,
        shouldDehydrateQuery: (query) =>
          defaultShouldDehydrateQuery(query) ||
          query.state.status === "pending",
      },
      // クライアントでのデータ復元(ハイドレーション)の設定
      hydrate: {
        deserializeData: SuperJSON.deserialize,
      },
    },
  });

このファイルでは、TanStack Query(旧React Query)のクライアント設定を行います。

このファイルでは、QueryClientのインスタンス生成を通じて、ServerComponentsでフェッチしたデータをClientComponentsへ渡すためのcreateQueryClient関数の定義を行っています。

staleTimeの設定:
データを再フェッチするまでの時間を設定しています。同じデータに対する新たなクエリは、キャッシュされたデータを使用し、再フェッチは行いません。

dehydrateの設定:
ServerComponentsで取得したデータをシリアライズし、ClientComponentsに転送できる形式に変換しています。

shouldDehydrateQuery の中にpending状態のクエリも含めることで、ServerComponentsで開始されたが完了していないクエリデータも転送可能になります。これにより、ClientComponents側でクエリが進行中であることを示すローディング状態や部分的に取得したデータ表示が可能になります。

hydrate の設定:
ServerComponentsで取得したデータをClientComponentsで再構築し、使用可能な状態にする際の設定です。SuperJSONのdeserializeメソッドを使用して、シリアライズされたデータを元の複雑なデータ構造(Date、Map、Setなど)に復元します。これにより、サーバーサイドのデータをクライアントサイドで正確に再現できます。

では、このcreateQueryClient関数を使って、ServerComponentsでtRPCを呼ぶための設定がどのように実装されているのかsrc/trpc/server.ts を見ていきましょう。

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";

// サーバーサイドのtRPCコンテキストを作成し、キャッシュする
const createContext = cache(() => {
  const heads = new Headers(headers());
  // `x-trpc-source`ヘッダーを設定して、リクエストがRSCから来ていることを明示
  heads.set("x-trpc-source", "rsc");

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

// Tanstack Queryのクエリクライアントを生成し、キャッシュする
const getQueryClient = cache(createQueryClient);

// tRPCの呼び出し関数を作成
const caller = createCaller(createContext);

// tRPCとTanstack Queryを統合するヘルパー関数
export const { trpc: api, HydrateClient } = createHydrationHelpers<AppRouter>(
  caller,
  getQueryClient
);

このファイルでは、tRPCとTanstack Queryを統合して、サーバーサイドでのデータフェッチとクライアントサイドでのハイドレーションを行うための設定を行なっています。

ServerComponentsでは、このファイルで作られたapiオブジェクトとHydrateコンポーネントを利用してtRPCを実行します。

createContext の作成:
tRPCのコンテキストにx-trpc-source ヘッダーを "rsc" (React Server Component) に設定します。これにより、tRPC がリクエストの出所を識別できます。

getQueryClientでクエリクライアント作成:
Tanstack Queryのクエリクライアントを生成する関数です。このクエリクライアントは、サーバーサイドでフェッチしたデータをクライアントサイドのキャッシュに効率的に保存し、管理するために使用されます。

cache 関数でラップされているため、同一リクエスト内での再計算を防ぎ、効率的なクライアント生成を実現します。

ヘルパー関数createHydrationHelpers<AppRouter>
RSCでtRPCを使用するためのヘルパー関数createHydrationHelpersが、apiオブジェクトとHydrateコンポーネントを作成します。

ServerComponentsはこのapiオブジェクトを通じてtRPCクエリを実行できます。プリフェッチされたデータをTanstack QueryのQueryClientのキャッシュに格納してくれる仕組みになっています。
HydrateClientコンポーネントは、サーバーサイドで取得したデータをクライアントサイドにハイドレートするために使用されます。

ここで型指定されているAppRouter 型は、src/server/api/root.tsで定義されているAPIの型です。このAppRouter 型を指定することで、Server ComponentでtRPCプロシージャの入力と出力の型が正確に推論されます。

src/trpc/react.tsxでtRPCとTanStack Queryを統合したプロバイダーを作成

"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") {
    // サーバーサイド: 常に新しいクエリクライアントを作成
    return createQueryClient();
  }
  // ブラウザ: シングルトンパターンを使用して同じクエリクライアントを保持
  return (clientQueryClientSingleton ??= createQueryClient());
};

// tRPCクライアントの作成(AppRouter型を使用して型安全性を確保)
export const api = createTRPCReact<AppRouter>();

// ルーター入力の型推論ヘルパー
export type RouterInputs = inferRouterInputs<AppRouter>;

// ルーター出力の型推論ヘルパー
export type RouterOutputs = inferRouterOutputs<AppRouter>;

// tRPCとTanstack Queryを統合するプロバイダーコンポーネント
export function TRPCReactProvider(props: { children: React.ReactNode }) {
  // クエリクライアントの取得
  const queryClient = getQueryClient();

  // tRPCクライアントの作成
  const [trpcClient] = useState(() =>
    api.createClient({
      links: [
        // ログの設定(コンソールに操作ログを表示させるデバッグ機能)
        loggerLink({
          enabled: (op) =>
            process.env.NODE_ENV === "development" ||
            (op.direction === "down" && op.result instanceof Error),
        }),
        // HTTP経由でtRPCリクエストをバッチ処理し、ストリーミングするための設定
        unstable_httpBatchStreamLink({
          transformer: SuperJSON,
          url: getBaseUrl() + "/api/trpc",
          headers: () => {
            const headers = new Headers();
            headers.set("x-trpc-source", "nextjs-react");
            return headers;
          },
        }),
      ],
    })
  );

  // QueryClientProviderとapi.Providerでラップしたコンポーネントを返す
  return (
    <QueryClientProvider client={queryClient}>
      <api.Provider client={trpcClient} queryClient={queryClient}>
        {props.children}
      </api.Provider>
    </QueryClientProvider>
  );
}

// ベースURLを取得する関数(環境に応じて適切なURLを返す)
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}`;
}

このファイルでは、tRPCとTanStack Queryを統合し、Next.jsアプリケーション全体でのデータフェッチングと状態管理のための基盤を作っています。

このファイルは主にClient Componentsのための設定を行っています。具体的には、tRPCクライアントの初期化、Tanstack Queryの設定、そしてこれらを統合するTRPCReactProviderの提供など、クライアントサイドでのデータフェッチングと状態管理のための基盤を整えています。

一方、Server Componentsとの連携は、getQueryClient関数内でサーバーサイドの判定を行い、SSRやRSC(React Server Components)でのデータフェッチに対応するQueryClientを生成することで実現しています。

getQueryClient 関数:
サーバーサイド(typeof window === "undefined")では、各リクエストに対して新しい QueryClient インスタンスを作成します。これにより、異なるユーザーやリクエスト間でのデータの混在を防ぎます。

クライアントサイドでは、シングルトンパターンを使用して単一の QueryClient インスタンスを維持します。これにより、アプリケーション全体で一貫したキャッシュ管理が可能になります。

TRPCReactProvider コンポーネント:
このコンポーネントは、tRPCとTanstack Queryの機能をアプリケーション全体で利用可能にします。

tRPCのプロバイダーapi.Provider をTanstack QueryのプロバイダーQueryClientProvider でラップすることで、クライアントサイドでのtRPCの呼び出し時にTanstack Queryのキャッシュを活用することができます。

src/app: Sever Components / Client ComponentsでAPIを呼ぶ

このapp ディレクトリで行っているのは主に以下2点です。

  • Client ComponentsでtRPCを実行するための設定(Route Handlersの作成、TRPCReactProvider でアプリケーション全体をラップ)
  • Sever Components / Client ComponentsでサンプルProcedureの呼び出し
├── app # Next.js App Routerを使用したフロントエンド部分
│   ├── _components
│   │   └── post.tsx # 投稿関連のコンポーネント(ClientComponents)
│   ├── api
│   │   └── trpc
│   │       └── [trpc]
│   │           └── route.ts # tRPCのHTTPハンドラー(Next.jsのRoute Handlers)
│   ├── layout.tsx # アプリケーション全体のレイアウトコンポーネント
│   └── page.tsx # アプリケーションのルートページコンポーネント(ServerComponents)

まずは、Client ComponentsでtRPCを実行するための設定ファイルから確認していきます。

src/app/layout.tsxTRPCReactProvider でアプリケーション全体をラップする

import "~/styles/globals.css";

import { GeistSans } from "geist/font/sans";
import { type Metadata } from "next";

import { TRPCReactProvider } from "~/trpc/react";

export const metadata: Metadata = {
  title: "Create T3 App",
  description: "Generated by create-t3-app",
  icons: [{ rel: "icon", url: "/favicon.ico" }],
};

export default function RootLayout({
  children,
}: Readonly<{ children: React.ReactNode }>) {
  return (
    <html lang="en" className={`${GeistSans.variable}`}>
      <body>
        <TRPCReactProvider>{children}</TRPCReactProvider>
      </body>
    </html>
  );
}

src/trpc/react.tsxで作成した<TRPCReactProvider> でアプリケーション全体をラップすることにより、tRPCクライアントが利用可能になります。これにより、すべてのコンポーネントでtRPCのクエリやミューテーションを使用できるようになります。

src/app/api/trpc/[trpc]/route.ts でAPIルートを定義

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";

// リクエストごとのtRPCコンテキストを作成する関数
const createContext = async (req: NextRequest) => {
  return createTRPCContext({
    headers: req.headers,
  });
};

// tRPCリクエストを処理するハンドラー関数
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,
  });

// GETとPOSTリクエストの両方に対してhandlerをエクスポート
// ClientComponentsからのtRPCリクエスト(GETまたはPOST)が
// このhandlerによって処理される
export { handler as GET, handler as POST };

このファイルは、クライアントサイドからのtRPCリクエストを受け取り、適切なtRPCルーターに渡す役割を果たしています。また、リクエストのコンテキストを作成し、エラーハンドリングを行います。

Next.jsのApp RouterのRoute Handler機能を使用し、handler関数をGETPOSTメソッドとしてエクスポートすることで、1つの関数で両方のHTTPメソッドを処理できるようにしています。

createContext 関数:
この関数は、各リクエストに対してtRPCコンテキストを作成します。ヘッダー情報を含むことで、認証情報などをtRPCプロシージャ内で利用できるようにしています。

fetchRequestHandler 関数:
この関数は、tRPCリクエストを処理する中心的な役割を果たします。エンドポイント、リクエスト、ルーター、コンテキスト作成関数を指定することで、適切なtRPCプロシージャにリクエストをルーティングします。

これで、Client ComponentsでtRPCを実行するための設定まで追うことができました。続いて、Sever Components / Client ComponentsでサンプルProcedureの呼び出しをどのように行っているか確認していきます。

どのコンポーネントでtRPCを呼び出しているか

tRPCリクエストを実行している箇所を確認していく前に、どのコンポーネントでサンプルProcedureを呼び出しているか説明します。

「サンプルプロシージャの確認」で説明したように、hello / create / getLatestProcedureが画面に実装されていました。これらのProcedureは、アプリケーション内の異なるコンポーネントで使用されています。

ファイル名 コンポーネントタイプ 使用Procedure
src/app/page.tsx ServerComponents hello / getLatest
src/app/_components/post.tsx ClientComponents create / getLatest

getLatest Procedureは、両方のファイルで使用されています。注目しておきたいのは、src/app/page.tsxsrc/app/_components/post.tsx の親コンポーネントであり、キャッシュしたデータを受け渡す処理がなされている点です。

では、具体的なコードを確認していきましょう。

ServerComponentsであるsrc/app/page.tsxhellogetLatest を呼び出す

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() {
  // サーバーサイドでtRPCクエリを実行
  const hello = await api.post.hello({ text: "from tRPC" });
  
  // サーバーサイドで認証セッションを取得
  const session = await getServerAuthSession();

  // 最新の投稿データをプリフェッチ(voidはこの結果を無視することを示す)
  void api.post.getLatest.prefetch();

  return (
  // サーバーサイドでフェッチされたデータをクライアントサイドへ渡すためのHydrateClient
    <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">
          {/* 略 */}
            <p className="text-2xl text-white">
              {hello ? hello.greeting : "Loading tRPC query..."}
            </p>
		      {/* 略 */}
          {/* ユーザーがログインしている場合のみ最新の投稿を表示 */}
          {session?.user && <LatestPost />}
        </div>
      </main>
    </HydrateClient>
  );
}

このファイルはアプリケーションのルートページコンポーネントであり、http://localhost:3000/ にアクセスした際に表示される画面です。Next.jsのApp Routerでは、すべてのコンポーネントがデフォルトでServerComponentとして扱われ、このファイルもServerComponentになります。

ServerComponentsでは、src/trpc/server.ts で設定したヘルパー関数であるapi を使うだけでtRPCを呼び出すことができます。

hello Procedureの呼び出し:
このprocedureは{ text: string }を入力として受け取り、{ greeting: string }を返します。Server Componentであるpage.tsx内ではこのように直接awaitを使用してこのprocedureを呼び出し、結果を取得できます。

getLatest Procedureの呼び出し:
このProcedureは最新の投稿を取得します。 void api.post.getLatest.prefetch();prefetch() は、tRPCのヘルパーメソッドです。クライアントサイドで使うuseQuery や useMutationのようなものです。

prefetch はfetchと違って結果をスローしません。 その代わりに、prefetch はクエリをキャッシュに追加し、それをハイドレートしてクライアントに送信します。

voidは、この操作の結果を無視することを示しています(prefetchは副作用として扱われます)。

<HydrateClient>の役割と子コンポーネントの<LatestPost />への影響:
<HydrateClient>コンポーネントは、サーバーサイドでフェッチされたデータ(この場合はgetLatestのデータを含む)をクライアントサイドのTanstack Queryキャッシュに渡します。

上記のvoid api.post.getLatest.prefetch(); でキャッシュされたデータはHydrateClientコンポーネントを使って、子コンポーネントの<LatestPost /> へ渡します。

これにより、LatestPostコンポーネントが後でこのデータを使用する際に、すでにキャッシュされたデータを利用できるため、パフォーマンスが向上します。

最後に、子コンポーネントの<LatestPost /> が定義されているsrc/app/_components/post.tsx でClientComponentsでのtRPCの実行方法を確認します.

ClientComponentsであるsrc/app/_components/post.tsxgetLatestcreateを呼び出す

"use client";

import { useState } from "react";
import { api } from "~/trpc/react";

export function LatestPost() {
  // 最新の投稿を取得するクエリを実行
  // Suspenseを使用してデータ取得中の状態を処理
  const [latestPost] = api.post.getLatest.useSuspenseQuery();

  // tRPCのユーティリティフックを取得
  const utils = api.useUtils();
  
  // 新しい投稿のタイトルを管理するためのstate
  const [name, setName] = useState("");

  // 新しい投稿を作成するためのミューテーションを設定
  const createPost = api.post.create.useMutation({
    onSuccess: async () => {
      // 投稿作成成功後、投稿リストを再取得
      await utils.post.invalidate();
      // 入力フィールドをクリア
      setName("");
    },
  });

  return (
    <div className="w-full max-w-xs">
      {/* 最新の投稿があれば表示、なければメッセージを表示 */}
      {latestPost ? (
        <p className="truncate">Your most recent post: {latestPost.name}</p>
      ) : (
        <p>You have no posts yet.</p>
      )}

      {/* 新しい投稿を作成するフォーム */}
      <form
        onSubmit={(e) => {
          e.preventDefault();
          // フォーム送信時に新しい投稿を作成
          createPost.mutate({ name });
        }}
        className="flex flex-col gap-2"
      >
        {/* 投稿タイトル入力フィールド */}
        <input
          type="text"
          placeholder="Title"
          value={name}
          onChange={(e) => setName(e.target.value)}
          className="w-full rounded-full px-4 py-2 text-black"
        />
        {/* 送信ボタン */}
        <button
          type="submit"
          className="rounded-full bg-white/10 px-10 py-3 font-semibold transition hover:bg-white/20"
          disabled={createPost.isPending}
        >
          {createPost.isPending ? "Submitting..." : "Submit"}
        </button>
      </form>
    </div>
  );
}

このファイルはLatestPostコンポーネントを定義しており、最新の投稿を表示し、新しい投稿を作成するためのフォームを提供します。ファイルの先頭に"use client"ディレクティブがあることから、これはClientComponentであることがわかります。

getLatest Procedureの呼び出し:
このProcedureは最新の投稿を取得します。親コンポーネントであるpage.tsxで使用されているHydrateClientコンポーネントを通じて、このデータがすでにキャッシュに存在するため、追加のネットワークリクエストなしで即座にデータを表示できます。useSuspenseQueryフックを使用しており、React Suspenseの機能を利用してデータ取得中に自動的にコンポーネントをサスペンドします。

create Procedureの呼び出し:
このProcedureは新しい投稿を作成します。データの変更操作を行うためのuseMutationフックを使用しています。onSuccessコールバック内でutils.post.invalidate()投稿関連のクエリキャッシュを無効化し、最新のデータを再取得します。

送信ボタンの制御:
createPost.isPendingプロパティを使用して、ミューテーション実行中はボタンを無効化しています。これにより、ユーザーが処理中に複数回ボタンをクリックすることを防ぎ、重複した投稿を防止します。

まとめ

本記事では、create-t3-appで生成されたプロジェクトのコード解説を通じてtRPCの基本について解説しました。

create-t3-appの雛形のコードはシンプルですが、tRPCとTanstack Queryとの連携、Next.jsのServerComponentsやClientComponentsでのデータキャッシュの扱いまで網羅的にたどることができました。この記事がtRPCの理解の一助になれば嬉しいです。

最後までお読みいただき、ありがとうございました。

Discussion