🎉

【Next.js × tRPC】tRPCを用いたRSCでのデータフェッチ

2024/08/27に公開

はじめに

昨今のWeb開発において、TypeScriptを用いたフロントエンドやバックエンドでの型安全なアプリケーション開発がますます求められています。その中で、注目を集めているのが「T3 Stack」という技術スタックです。
効率的に型安全なアプリケーションを構築できることから、T3 Stackでの開発を推進しています。

T3 Stackとは

T3 Stackとはsimplicity(簡潔さ)、modularity(モジュール性)、full-stack type safety(フルスタックの型安全)を追求した思想に焦点を当てています。
そしてそれらを実現するために以下6つの技術スタックが採用されています。
✅ Next.js
✅ tRPC
✅ NextAuth.js
✅ Prisma
✅ Tailwind CSS
✅ Typescript

スキルセットの一つであるNext.jsのApp Routerは、RSC(React Server Components)の導入により、サーバーサイドとクライアントサイドの境目を柔軟に設定できるようになりました。
さらに、Server Actionsが登場し、従来クライアントサイドで行なっていた処理もサーバーサイドで実行できるようになりました。
これにより、サーバーサイドとクライアントサイドのどちらで処理するかをよく考えて実装することでパフォーマンス向上につながります。

tRPCは、フロントエンドとバックエンド間でTypeScriptの型を共有し、型安全な開発ができるRPCフレームワークです。フロントエンドとバックエンドが同じコードベースで型を共有できるためコードジャンプ機能や型補完機能によって開発体験が向上するという大きな導入メリットがあります。

今回は、そんなApp RouterとtRPCを使用したデータフェッチに関する内容になっています。
まずtRPCを使用するメリットを紹介し、その後以下の4本立てで、それぞれの特徴やtRPCを使用した具体的な実装方法を解説します。

  1. Server Componentsでのデータフェッチ
  2. Server Actions
  3. 一部Client Componentsを用いたServer Componentsでのデータフェッチ(リアルタイム更新)
  4. Tanstack Queryを用いたClient Componentsでのデータフェッチ

https://zenn.dev/kiwichan101kg/articles/279cc65988a39b

tRPCを使用するメリット

tRPCを使用するメリットを使用しない場合のfetchと比較して解説します。

型の自動適用

tRPCの最大の特徴の一つは、バックエンドのAPIで定義された型がフロントエンドで自動的に適用されることです。
fetchを使用する場合、フロントエンド側で手動で型定義を行い、取得したデータを型付けする必要があります。もしAPIのレスポンスが変更された場合、フロントエンド側の型定義も手動で更新する必要があります。(fetchでもAPIレスポンスの型を自動生成するツールは存在すると思いますが一例として手動で型付けを行う例を取り上げます。)

fetchを使用した場合のコード例

// バックエンド
app.get('/api/user/:id', (req, res) => {
  const userId = req.params.id;

  // バックエンドでデータ型を定義する
  const user: User = {
    id: userId,
    name: 'John Doe',
  };
  res.json(user);
});
// フロントエンド
interface User {
  id: string;
  name: string;
}

const response = await fetch("/api/user/123");
// 呼び出し側で取得したデータを手動で型付けする必要がある。
const result: User = await response.json();

一方、tRPCではバックエンドで定義した型がフロントエンドにも自動的に反映されます。これにより、フロントエンド側での型定義が不要になります。

tRPCを使用した場合のコード例

// バックエンド
// API定義時にあらかじめ型を定義しておく
const appRouter = t.router({
  getUser: t.procedure
    .input(z.string())
    .query((req) => {
      return { id: req.input, name: "John Doe" };
    }),
});
// フロントエンド
// バックエンドで定義した { id: string, name: string } 型が自動的に適用される
const result = await trpc.getUser.query("123");

認証をtRPCのプロシージャに組み込むことができるため、再利用性が高い

tRPCでは、一度認証ロジックをtRPCのプロシージャとして定義すれば、複数のエンドポイントでそれを再利用できます。また、プロシージャを拡張することで、簡単に追加の認証チェックを組み込むことも可能です。
これにより、全てのAPIエンドポイントに対して一貫した認証チェックを行うことができます。
fetchを使用する場合、管理者権限をチェックする追加ロジックなどは各エンドポイントで個別に記述する必要があります。これにより、コードの重複や一貫性の欠如が生じやすくなります。

fetchを使用した場合のコード例

// バックエンド
// 基本的な認証チェックを行うミドルウェア
function authenticateToken(req, res, next) {
// ユーザーの認証チェック
...
    next();
  });
}

----
// 追加の認証ロジックが必要なエンドポイント
app.post('/api/admin/posts', authenticateToken, (req, res) => {
  // 特定のエンドポイントでのカスタム認証を個別に実装する必要がある
  if (req.user.role !== 'admin') {
    return res.status(403).json({ message: 'Forbidden' });
  }
  res.status(201).json({ message: 'Admin post created' });
});
// フロントエンド
async function createPost(name: string, token: string) {
  const response = await fetch('/api/admin/posts', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${token}`, 
    },
    body: JSON.stringify({ name }),
  });

  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.message);
  }

  const post = await response.json();
  return post;
}

一方、tRPCは認証チェックを行うプロシージャを一度定義すれば、他のエンドポイントで簡単に再利用でき、さらに、その認証プロシージャを拡張する形で管理者権限チェックなどの追加認証ロジックも柔軟に組み込むことができます。

tRPCを使用した場合のコード例

// バックエンド
// 基本的な認証チェックを行うプロシージャを作成
const protectedProcedure = t.procedure.use(async ({ ctx, next }) => {
  if (!ctx.session || !ctx.session.user) {
    throw new TRPCError({ code: 'UNAUTHORIZED' });
  }
  return next({
    ctx: {
      ...ctx,
      user: ctx.session.user,
    },
  });
});

// protectedProcedureを拡張して権限チェックを行うプロシージャを作成
const adminProcedure = protectedProcedure.use(async ({ ctx, next }) => {
  if (ctx.user.role !== 'admin') {
    throw new TRPCError({ code: 'FORBIDDEN' });
  }
  return next();
});


---
// 各エンドポイントで認証+権限チェックを行うプロシージャを再利用
export const adminRouter = t.router({
  createAdminPost: adminProcedure.mutation(async ({ ctx, input }) => {
    return { message: 'Admin post created' };
  }),
});
// フロントエンド
// 認証と権限チェックが組み込まれたAPIを呼び出す
const post = await trpc.admin.createAdminPost.mutate({ name: "New Admin Post" });

このように tRPCでは、型の自動適用や認証ロジックの再利用性において大きなメリットがあります。次からは、tRPCを使用したデータフェッチの方法について詳しく解説します。

1. Server Componentsでのデータフェッチ

Server Componentsとは

RSCはデフォルトでServer Componentsとして機能します。Server Componentsとはサーバーサイドでのレンダリングを行うコンポーネントです。これにより、サーバー上でデータフェッチを行い、その結果をクライアントに返します。

Server Componentsの利点

Server Componentsの主な利点は、以下の通りです。
初期ページロードの高速化:サーバーで事前にレンダリングされたコンテンツを提供するため、ユーザーがページにアクセスした際の表示速度が向上します。
SEOの向上:サーバー側で完全なHTMLが生成されるため、Googleクローラーがページ内容を検知しやすく、SEOに有効です。
JavaScriptバンドルサイズの削減: クライアントサイドで必要なJavaScriptが減ることで、ページの読み込みが軽量化され、パフォーマンスが向上します。

tRPCを用いた実装例

tRPCとServer Componentsを組み合わせることで、型安全かつ効率的なデータフェッチを実現できます。以下は、Server ComponentsでtRPCを使用してデータフェッチする基本的な実装例です。
やっていること:メッセージを取得して表示する

①Server Components内で直接呼び出す

page.tsx
import { api } from "@/trpc/server";
export default async function Page() {
  // 直接呼び出すことができる
  const messages = await api.chat.getAllMessage();
  return (
    <div>
      {messages.map((message) => (
        <p key={message.id}>{message.name}</p>
      ))}
    </div>
  );
}

このようにServer Components内で直接呼び出すことができます。

2. Server Actions

Server Actionsとは

Server ActionsはNext.jsのv13で登場し、v14で安定版となった機能です。サーバーサイドで実行される関数をClient Componentsから直接呼び出すことができます。これにより、従来クライアントサイドで処理していたフォーム送信やデータ更新などの操作を、サーバーサイドで簡単に実装できるようになりました。
https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations

Server Actionsの利点

  • セキュリティの向上:クライアントサイドでの処理を減らし、サーバーサイドで行うことでセキュリティリスクが軽減されます。
  • パフォーマンスの最適化:クライアントとサーバー間の通信を最適化し、ネットワーク負荷を軽減します。
  • 開発コードの効率化:DB操作や外部API呼び出しを直接サーバーサイドに書くことができるのでその分のAPIエンドポイントを減らすことができます。

tRPCを用いた実装例

以下は、tRPCとServer Actionsを用いてデータフェッチする基本的な実装例です。

やっていること:メッセージ履歴が表示されている状態でメッセージを送信した後、最新のメッセージを表示する

①フォーム送信関数の作成

フォーム送信時の処理関数を別のファイルに切り出します。
Server Actionsの中は"use server"ディレクティブを使用します。サーバーサイドの処理なのでtRPCを直接呼び出すことができます。

lib/actions.ts
"use server";
import { api } from "@/trpc/server";

export async function chatFormAction(formData: FormData) {
  const message = formData.get("message")
  // 直接呼び出すことができる
  const res = await api.chat.create({ name: message });
}

②formのaction属性にフォーム送信関数設定

HTMLの<form>タグのaction属性に先ほど切り出したフォーム送信時の処理関数を設定します。

chat/page.tsx
import { chatFormAction } from "@/lib/actions";
import { api } from "@/trpc/server";

export default async function Page() {
  const messages = await api.chat.getAllMessage();

  return (
    <div>
      {messages.map((message) => (
        <p key={message.id}>{message.name}</p>
      ))}
      <form action={chatFormAction}>
        <input type="text" name="message" />
        <button type="submit">送信</button>
      </form>
    </div>
  );
}

テキストボックスの入力値を受け取るためには②の<input>タグのname属性と①のformData.get("")の引数を紐付ける必要があります。
actions.ts内の関数でformDataを引数に取りformData.get("message")とすることで値を取得できます。

③フォームのバリデーション

フォーム送信関数内の処理に戻ります。
入力時バリデーションにはZodというスキーマバリデーションライブラリを使用しています。

lib/actions.ts
"use server";
+ import { z } from "zod";
import { api } from "@/trpc/server";

+ const FormSchema = z.object({
+  message: z.string(),
+ });

export async function chatFormAction(formData: FormData) {
- const message = formData.get("message")
+ const { message } = FormSchema.parse({
+   message: formData.get("message"),
+ });
  const res = await api.chat.create({ name: message });
  }

Zodを用いたバリデーションはどのような型指定をしているかが直感的にわかりやすいです。
FormSchema.parseを使用して、formDataから取得したデータのバリデーションを実施します。バリデーションに失敗した場合はエラーがスローされます。

⑤再検証とリダイレクト

最後に、フォーム送信後にUIを更新するための再検証とリダイレクト処理を追加します。Next.jsにはブラウザに一定期間保存するクライアント側キャッシュが存在します。メッセージを送信した後、履歴を更新するためにキャッシュをクリアしてサーバーへの新しいリクエストをトリガーする必要があります。

lib/actions.ts
"use server";
import { api } from "@/trpc/server";

export async function chatFormAction(formData: FormData) {
  const message = formData.get("message")
  // 直接呼び出すことができる
  const res = await api.chat.create({ name: message });
+ // 再検証
+ revalidatePath("/chat");
+ // リダイレクト
+ redirect("/chat");
}

revalidatePathを使用することで特定のパスのキャッシュされたデータをオンデマンドで消去することができます。
https://nextjs.org/docs/app/api-reference/functions/revalidatePath

再検証した後にredirectでユーザーをリダイレクトさせることでサーバーから最新のデータが取得されUI状に表示されます。
https://nextjs.org/docs/app/api-reference/functions/redirect

3. 一部Client Componentsを用いたServer Componentsでのデータフェッチ(リアルタイム更新)

リアルタイムでデータが更新される機能を実装する場合、Client Componentsを活用するのが有効です。
例えば、ユーザーが文字を入力するとリアルタイムに検索結果が更新されるような機能は一部Client Componentsを取り入れるのが最適です。

Client Componentsとは

Client Componentsは、クライアントサイドで実行されるコンポーネントです。appディレクトリ内のファイルはデフォルトでServer Componentsとして扱われますが、"use client"を使用することで、Client Componentsとして指定できます。

Client Componentsの利点

  • リアルタイムなデータ更新: ユーザーの操作に合わせて、データを動的に更新できます。
  • インタラクティブなユーザーインターフェース:ユーザー入力に即座に反応し、動的なUI更新ができます。
  • 状態管理:複雑なフォームやユーザーインターフェースの状態を管理できます。 Server Componentsでは状態管理ができません。

tRPCを用いた実装例

以下は、tRPCと一部Client Componentsを組み合わせて、Server Componentsでデータフェッチを行うリアルタイム検索の例です。
やっていること:ユーザーが検索バーに入力した文字に一致するメッセージ履歴をリアルタイムで表示更新する

今回作成する検索機能はクライアントサイドとサーバーサイドにまたがります。ユーザーがクライアントサイドで文字を入力すると、クエリパラメータが更新され、それをサーバーサイドで受け取ります。更新されるクエリパラメーターをキーにサーバーサイドでデータが取得され、検索結果テーブルが再レンダリングされる仕組みで実現させます。

①Server ComponentsとClient Componentsの配置

検索バーとしてユーザーの入力をキャプチャするSearchコンポーネントのみをClient Componentsに切り出します。
データフェッチ自体はServer ComponentsであるTableコンポーネント内で行います。

search/page.tsx
import Search from "@/components/Serach";
import Table from "@/components/Table";

export default async function Page() {
  return (
    <div>
      <Search /> // Client Components
      <Table /> // Server Components
    </div>
  );
}

Search.tsx ユーザーの入力を受け取る

SearchコンポーネントはClient Componentsにするため"use client"ディレクティブにします。
onChangeを使用してユーザーの入力値を受け取ります。

components/Search.tsx
"use client";

export default function Search() {

  function handleSearch(term: string) {
    console.log(term);
  }
  return (
    <div >
      <input
        onChange={(e) => {
          handleSearch(e.target.value);
        }}
      />
    </div>
  );
}

Search.tsx クエリパラメーターを更新する

ユーザーの入力値をURLのクエリパラメーターに反映させます。ここで重要になるのがnext/navigationから提供される3つのフックです。今回は以下の用途で使用します。

  • useSearchParams:現在のURLのクエリパラメーターにアクセスすることができる
  • usePathname:現在のURLを読み取ることができる
  • useRouter:ユーザーをナビゲートすることができる
components/Search.tsx
"use client";
import { useSearchParams, usePathname, useRouter } from "next/navigation";

export default function Search() {
+ const searchParams = useSearchParams();
+ const pathname = usePathname();
+ const { replace } = useRouter();

  function handleSearch(term: string) {
+   const params = new URLSearchParams(searchParams);
+   if (term) {
+     params.set("query", term);
+   } else {
+     params.delete("query");
+   }
+   // 現在のパス+作成したクエリのパスに遷移
+   replace(`${pathname}?${params.toString()}`);
  }

  return (
    <div className="">
      <input
        className=""
        onChange={(e) => {
          handleSearch(e.target.value);
        }}
      />
    </div>
  );
}

URLSearchParamsは、クエリパラメーターを操作するためのユーティリティメソッドを提供するWeb APIです。
https://developer.mozilla.org/ja/docs/Web/API/URLSearchParams

params.set("query", term);とすることでクエリパラメーターに入力値を設定します。
入力値がない場合はparams.delete("query");でクエリパラメーターを削除します。

replace(${pathname}?${params.toString()});でURLを/search?query=入力値のように設定し、ユーザーをナビゲートします。

Search.tsx URLと入力フィールドを同期させる

defaultValueにクエリパラメーターを設置することでURLと入力フィールドを同期させます。

components/Search.tsx
return (
    <div className="">
      <input
        className=""
        onChange={(e) => {
          handleSearch(e.target.value);
        }}
+       // URLと入力を同期させる
+       defaultValue=
+ {searchParams.get("query")?.toString()}
      />
    </div>
  );

page.tsx クエリパラメーターを取得しTable.tsxに渡す

App Routerにおけるpage.tsxはpropsでsearchParamsというプロパティを受け取ることができます。たとえば、/search?query=aaaというURLの場合{query:"aaa"}のようなオブジェクトを受け取ります。
https://nextjs.org/docs/app/api-reference/file-conventions/page#searchparams-optional

page.tsxでは検索バーで更新されたクエリパラメーターを受け取り、Table.tsxにpropsで渡すことができます。

search/page.tsx
  import Search from "@/components/Serach";
  import Table from "@/components/Table";

- export default async function Page() {
+ export default async function Page({
+   searchParams,
+ }: {
+   searchParams?: {
+     query?: string;
+   };
+ }) {
+   const query = searchParams?.query ?? "";
    return (
      <div>
        <Search /> // Client Components
-       <Table /> // Server Components
+       <Table query={query} /> // Server Components
      </div>
    );
  }

Table.tsx propsで受け取ったクエリパラメーターをキーにデータフェッチする

page.tsxからpropsで受け取ったクエリパラメーターをキーにデータフェッチを行います。
Server Components内なのでtRPCを直接呼び出すことができます。

compornents/Table.tsx
import { api } from "@/trpc/server";

export default async function Table({ query }: { query: string }) {
  const messages = await api.chat.getSearchMessage({ name: query });
  return (
    <div className="">
      {messages.map((message) => (
        <p key={message.id}>{message.name}</p>
      ))}
    </div>
  );
}

Table.tsxをわざわざ切り出した理由はpageコンポーネントから受け取るpropsの値が変わるごとに再レンダリングされるためです。(これはReactの動作に基づいています。)
したがって、クエリパラメーターが更新されるごとにデータフェッチされ、検索結果がリアルタイムで更新されます。

4. Tanstack Queryを用いたClient Componentsでのデータフェッチ

Tanstack Queryとは

Tanstack Query(旧React Query)は、クライアントサイドでのデータフェッチを効率的に管理するためのライブラリです。tRPCと組み合わせることで、データの取得、更新、キャッシュをシンプルに行うことができます。
tRPCとTanstack Queryの相性がよく、公式でも導入方法が記載されています。
https://trpc.io/docs/client/react

Tanstack Queryの利点

  • データのキャッシュ:データをキャッシュすることで、サーバーへのリクエスト回数を減らし、パフォーマンスを向上させることができます。
  • データの更新:データの更新を自動的に検知し、UIを更新することができます。
  • エラーハンドリング:エラー発生時の処理を自動的に行い、ユーザーエクスペリエンスを向上させることができます。
  • 非同期処理の状態管理:データの取得状態、エラー状態、ローディング状態などを管理することができます。

tRPCを用いた実装例

以下は、tRPCとTanstack Queryを用いたClient Componentsでのデータフェッチの例です。
やっていること:メッセージ履歴が表示されている状態でボタン押下でメッセージを送信した後、最新のメッセージを表示する。送信中はボタンを非活性で「Submitting...」にする

①Client Componentsに切り出す

状態管理を行うために必要な部分のみをClient Componentsとして切り出します。

clientpost/page.tsx
import { ClientPost } from "@/components/ClientPost";

export default async function Page() {
  return (
    <div>
      <ClientPost /> // Client Components
    </div>
  );
}

②useQueryとuseMutationで状態管理

"use client"ディレクティブにします。useQueryとuseMutationは以下のように使い分けます。

  • useQuery:サーバーからデータを取得するためのクエリを管理します。主に読み取り操作に使用されます。
  • useMutation:サーバーに対してデータの変更を行う操作(作成、更新、削除など)を管理します。主に書き込み操作に使用されます。
components/ClientPost.tsx
"use client";
import { useState } from "react";
import { api } from "@/trpc/react";

export function ClientPost() {
  const [message, setMessage] = useState("");

  // 最新のメッセージ取得
  const { data: latestPost } = api.chat.getLatest.useQuery();
  // メッセージを投稿
  const createPost = api.chat.create.useMutation({
    onSuccess: async () => {
      setMessage("");
    },
  });

  return (
    <div>
      {latestPost ? (
        <p>Your most recent post: {latestPost.name}</p>
      ) : (
        <p>You have no posts yet.</p>
      )}
      <form
        onSubmit={(e) => {
          e.preventDefault();
          createPost.mutate({ name: message });
        }}
      >
        <input
          type="text"
          placeholder="Title"
          value={message}
          onChange={(e) => setMessage(e.target.value)}
        />
        <button type="submit" disabled={createPost.isPending}>
          {createPost.isPending ? "Submitting..." : "Submit"}
        </button>
      </form>
    </div>
  );
}

今回の場合、最新のメッセージを取得する際はuseQuery、ボタン押下でのメッセージ投稿をuseMutationで管理します。
useMutationではonSuccessに成功後の処理を書くことができます。
createPost.mutate({ name: message });で実際にメッセージを投稿しています。
createPost.isPendingでは非同期処理の状態を取得することができ、pending中に非活性にすることができます。

③送信後に最新のメッセージを更新

最新のメッセージを表示するためにキャッシュの削除を行う必要があります。

export function ClientPost() {
  const [message, setMessage] = useState("");
+ // tRPC のユーティリティ関数を提供するフック
+ const utils = api.useUtils();

  // 最新のメッセージ取得
  const { data: latestPost } = api.chat.getLatest.useQuery();
  // メッセージを投稿
  const createPost = api.chat.create.useMutation({
    onSuccess: async () => {
+     // chatに関するキャッシュが削除される
+     await utils.chat.invalidate();
      setMessage("");
    },
  });

  return (

  );
}

await utils.post.invalidate(); を実行することによって、chatに関連するクエリのキャッシュが無効化されます。この無効化によって、該当するキャッシュが削除され、api.post.getLatest.useQuery()が再度実行されます。
したがって、UI上で最新のメッセージが更新されます。

まとめ

いかがでしたか?今回はtRPCを用いたRSCでのデータフェッチ例を紹介しました。
記事の内容からもわかるようにサーバーサイドとクライアントサイドの処理を意識して切り分けることが重要です。

次回はT3 Stackの技術セットの一つであるNextAuth.jsを用いた認証システムの構築について執筆予定です。App RouterとtRPCを絡めた応用的な内容を目指すのでご期待ください!

技術書典17に共同著書で出版することが決まりました!🎉

この度、技術書典17にて「T3 Stack」に関する書籍を共同著書で出版することが決まりました!🎉

この本では、各技術の基礎からT3 Stackとしての連携方法までを丁寧に解説し、体系的に学べる内容を目指しています。T3 Stackに興味がある方、これから学びたい方、ぜひフォローして最新情報をお待ちください!🙌
https://techbookfest.org/event/tbf17

興味がある方はこちらの記事もご覧ください!
https://zenn.dev/maicom/articles/efafe3fc3f40e2
https://zenn.dev/blueish/articles/4b2ae3781ade57
https://zenn.dev/blueish/articles/61526c0983362e

Discussion