💎

アプリ開発をしながらtRPCとZodを学ぶ

2023/05/31に公開

はじめに

今回はtRPCについて周辺の用語解説及び整理に加え、簡易的なTODOアプリを開発しながら、具体的な使い方を解説していきます。

この記事の主な対象者

  • tRPCやZodについて基礎から学びたい人
  • API開発及びフロントエンドとの繋ぎ込みを担当している人
  • tRPCを使ってアプリ開発をしてみたい人

本記事の目標

  • tRPCやZodを含むその他周辺技術の用語の整理
  • 簡易的なTODOアプリ開発を通してtRPCの使い方を学ぶ

用語解説

tRPCを理解するためにその周辺知識の整理と今回作成するTODOアプリで登場する用語の解説をしていきます。

T3 Stack

今回は開発する簡易アプリではT3 Stackは採用していませんが、tRPCの周辺用語でもあるので、簡単に解説をします。

T3 Stackは下記の3つの思想をもとにTheo氏によって作成されたWeb開発スタックです。

  • simplicity(簡潔さ)
  • modularity(モジュール性)
  • full-stack typesafety(フルスタックの型安全)

上記の3つの思想を実現するために、T3 Stackでは下記の6つの技術が採用されています。

  • Next.js
  • tRPC
  • Tailwind CSS
  • Typescript
  • Prisma
  • NextAuth.js

T3 Stackについてより詳しい解説を知りたい人は下記のドキュメントを読んでみることをおすすめします。

https://create.t3.gg/en/introduction

今回は「tRPCはT3 Stackでも採用されている技術なのか〜」程度の理解で大丈夫です。

tRPC

https://trpc.io/

tRPCはスキーマやコード生成なしに、完全に型安全なAPIを簡単に構築し使用することができる技術です。

昨今のフロントエンドとサーバーサイド(API)の繋ぎ込みにおいては、静的に型付けを行い共有するような方法が必要とされています。

その中でTypeScriptを利用した型安全なAPIを構築するためのシンプルな技術がtRPCとなっています。

簡単に言うと、サーバー側(API側)で定義したインターフェースをそのままクライアント(NextやTypeScritp等)で取り込んでつなぎ込みができます。

言葉だけだと少しわかりずらいので、この後の章で具体的なコードを紹介します。

Zod

https://zod.dev/

ZodはTypeScript Firstなバリデーションライブラリで下記のような特徴があります。

  • TypeScript最優先
  • 開発者に優しい設計
  • 型推論
  • 組み立てやすさ
  • ゼロ依存性
  • ユニバーサル
  • 軽量
  • イミュータブ
  • 簡潔なチェイン可能なインターフェース

Zodの類似ライブラリーとしては下記のようなものがあります。

  • Yup
  • class-validator

todoの値を入力する想定で具体的なコード例を紹介します。

import { z } from 'zod';

// スキーマを定義
const TodoSchema = z.object({
  id: z.number(),
  title: z.string().max(50), // 最大長を50に設定
  completed: z.boolean(),
});

// ユーザーからの入力される値の想定 (本来はinputから入ってくる)
const todoInput = {
  id: 1,
  title: 'Buy milk',
  completed: false,
};

try {
  // バリデーション
  const validatedTodo = TodoSchema.parse(todoInput);
  console.log(validatedTodo);
} catch (error) {
  if (error instanceof z.ZodError) {
    // エラーメッセージを出力
    console.log(error.errors);
  } else {
    // 他のエラーハンドリング
  }
}

入力値がバリデーションと合わない場合(タイトルが50文字を超えている場合等)にerrorの処理が走ります。

Zodを使わない場合、下記のような正規表現を書く必要があります。

// メールアドレスの正規表現
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

// ユーザーからの入力
const emailInput = 'john@example.com';

if (emailRegex.test(emailInput)) {
  console.log('Valid email');
} else {
  console.log('Invalid email');
}

Zod利用すると下記だけで済むようになります。

import { z } from 'zod';

// スキーマを定義
const EmailSchema = z.string().email();

// ユーザーからの入力
const emailInput = 'john@example.com';

try {
  // バリデーション
  const validatedEmail = EmailSchema.parse(emailInput);
  console.log('Valid email:', validatedEmail);
} catch (error) {
  if (error instanceof z.ZodError) {
    // エラーメッセージを出力
    console.log('Invalid email:', error.errors);
  } else {
    // 他のエラーハンドリング
  }
}

Prisma

https://www.prisma.io/

Prismaは、Node.jsとTypeScriptのためのオープンソースのデータベースORM(Object-Relational Mapping)ツールです。

データベースとアプリケーションコード間の橋渡しを担当し、開発者がSQLを直接書かずにデータベース操作を行うことができます。

Prismaは下記のような特徴があります。

  • 自動生成され、型安全なデータベースクライアント
  • 宣言的なデータベーススキーママイグレーション
  • データベーススキーマの内省
  • 複雑なデータベースクエリのサポート

簡易的ですが、Prismaを使った具体的なコード例です。(一部抜粋)

import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();

// todoの新規作成
const newTodo = await prisma.todo.create({
    data: {
      title: "New Todo",
      body: "This is a new todo",
    },
  });
// todoの一覧取得
const allTodos = await prisma.todo.findMany();

上記のような記述でTODOテーブルに対してデータを書き込んだり読み込んだりすることができます。

Prismaを使わなかった場合は下記のようにDBへの操作に対してはSQLを書く必要があります。(あくまで一例)

  await client.connect();

  // todoの新規作成
  const insertText = 'INSERT INTO todos(title, body) VALUES($1, $2) RETURNING *';
  const insertValues = ['New Todo', 'This is a new todo'];
  const insertRes = await client.query(insertText, insertValues);
  
  // todoの一覧取得
  const selectRes = await client.query('SELECT * FROM todos');

tRPCを使ったTODOアプリの開発

具体的にtRPCをTODOアプリを開発しながら使い方を学んでいきます。

tRPCを使う前に、tRPCを利用しない従来のAPI結合の具体例と課題を見ていきます。

従来のAPI結合

従来のサーバーサイドとフロントエンド側のAPI結合は下記の手順で行うことが多いかと思います。(あくまで一例)

  1. サーバー側でAPIの実装
  2. OpenAPIドキュメントを生成しフロントへ共有
  3. フロントはOpenAPIドキュメントからモックサーバーの立ち上げ
  4. モックサーバーのエンドポイントに対してAPI結合をしUIを表示

説明だけだとわかりずらいので具体的なコードを見ていきます。

なおこの部分は自分の手元でやらなくても大丈夫です。なんとなく「確かにいつもこんな感じでAPI結合しているな〜」程度で理解していただければと思います。

簡易的なTODOアプリの例で見ていきます。1のサーバー側のAPIは実装済みの想定で2から見ていきます。

OpenAPIドキュメントを生成しフロントへ共有

下記のようなAPIドキュメントがサーバー側から共有されたとします。

共有されたopenAPIのyamlファイルの内容。

openapi.yaml
openapi: 3.0.0
info:
  title: Todo API
  version: 1.0.0
paths:
  /todos:
    get:
      summary: Get all todos
      responses:
        "200":
          description: A list of todos
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Todo"
    post:
      summary: Create a new todo
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/Todo"
      responses:
        "200":
          description: The created todo
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Todo"
components:
  schemas:
    Todo:
      type: object
      properties:
        id:
          type: integer
          format: int64
          readOnly: true
        title:
          type: string
        body:
          type: string
      required:
        - title
        - body

なおOpenAPIの具体的な作り方に関しては下記の記事を参考にしてください。本記事ではあくまで紹介程度なので、作り方まで理解はしなくてOKです。

https://qiita.com/KNR109/items/7e094dba6bcf37ed73cf

拡張機能を使って開くと下記のような表示がされます。

フロントはOpenAPIドキュメントからモックサーバーの立ち上げ

先ほど共有されたOpenAPIをモックサーバーで立ち上げます。

詳しい立ち上げ方は下記の記事を参考にしてみてください。

https://qiita.com/KNR109/items/7e094dba6bcf37ed73cf

一応結論だけ、Next.jsのプロジェクトを環境構築しpackage.jsonファイルに下記を追加します。

package.json
  "scripts": {
    "mockapi": "docker run --rm -it -p 3001:4010 -v ${PWD}:/tmp -P stoplight/prism:4 mock -h 0.0.0.0 --cors /tmp/openapi.yaml"
  },

こちらを追加した上で、ルートに先ほど作成したOpenAPI.yamlを配置しDockerを起動し下記のコマンドを実行します。

$ npm run mockapi

その上で、下記にアクセスすると実際にデータが返ってきていることが確認できます。(Postmanとかでの確認も可)

http://localhost:3001/todos

これで上記のエンドポイントを実際にNext.js側でaxios等を利用し叩くことによってデータをUIとして表示できる準備ができました。

モックサーバーのエンドポイントに対してAPI結合をしUIを表示

useEffect(useAcync)を利用して下記のようにAPIを結合することができます。

index.tsx
import Link from "next/link";

import axios from "axios";
import { useAsync } from "react-use";

// todoオブジェクトの型定義
type Todo = {
  id: number;
  title: string;
  body: string;
};

const getTodos = async (): Promise<Todo[]> => {
  const response = await axios.get<Todo[]>("http://localhost:3001/todos");
  return response.data;
};

export default function IndexPage() {
  useAsync(async () => {
    const todos = await getTodos();
    console.log(todos);
  }, []);
  return <Link href="/todo">TODOページへ</Link>;
}

npm run devでローカル環境を立ち上げてconsoleを確認するとデータが取得できていることが確認できます。

従来のAPI結合における問題点

この方法でもAPI結合自体は問題なく行えます。しかし下記のような課題もあります。

  • 型安全の欠如
  • OpenAPIドキュメントの更新が手間
  • OpenAPIドキュメントが最新になっていなとデータの整合性が合わない

例えば、先ほどのtodoのレスポンスデータにstring型のcategoryとうカラムがAPI側で追加されたとします。

もしこれがOpenAPIドキュメントへの反映漏れがあった場合、データの不整合が発生します。

それ以外にもidがnumber型からstring型に変わった場合など色々と不便な面があります。

tRPCを利用することで、こういったサーバー側のデータ構造が変わった時もの不整合や型安全の欠如を防ぐことができます。

tRPCを使った簡単な例

具体てにTODOアプリを作る前にチュートリアルとして、公式ドキュメントに載っている簡単な例をやっていきます。(やったことがある人は飛ばしてしまって大丈夫です)

https://trpc.io/docs/nextjs/setup

環境構築

まずはざっくり環境構築していきます。下記のコマンドでnext.jsとtypescriptの環境を立ち上げます。こちらの環境は後の簡易TODOアプリでも利用する環境になっています。

$ npx create-next-app trpc-project --ts

今回はDBはDockerを利用するのでルートディレクトリに下記のファイルを作成します。
具体的なDBの起動は次の章で解説をします。チュートリアルでは使いません。

docker-compose.yml
version: "3.8" # 使用するDocker Composeのバージョンを指定します。

services:
  dev-postgres:
    image: postgres:14.4-alpine # PostgreSQLのバージョン14.4を基にしたAlpine Linuxイメージを使用します。
    ports:
      - 5434:5432 # ホストマシンの5434ポートとコンテナの5432ポートを紐づけます。これにより、ホストマシンから5434ポートを介してPostgreSQLにアクセスできます。
    environment:
      POSTGRES_USER: trpc # PostgreSQLのユーザー名を指定します。
      POSTGRES_PASSWORD: trpc # PostgreSQLのパスワードを指定します。
      POSTGRES_DB: trpc # PostgreSQLのデータベース名を指定します。
    restart: always # コンテナが停止したときに常に再起動します。これにより、エラーやサーバーの再起動後でも自動的にコンテナが起動します。
    networks:
      - lesson # このサービスを'lesson'という名前のネットワークに接続します。

networks:
  lesson: # 'lesson'という名前のネットワークを作成します。上記のサービスがこのネットワークに接続します。

次に必要なパッケージを入れていくのでpackage.jsonを下記にしてください。

package.json
{
  "name": "trpc-front",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "mockapi": "docker run --rm -it -p 3001:4010 -v ${PWD}:/tmp -P stoplight/prism:4 mock -h 0.0.0.0 --cors /tmp/openapi.yaml"
  },
  "dependencies": {
    "@prisma/client": "^4.9.0",
    "@tanstack/react-query": "^4.29.7",
    "@trpc/client": "^10.28.0",
    "@trpc/next": "^10.28.0",
    "@trpc/react-query": "^10.28.0",
    "@trpc/server": "^10.28.0",
    "axios": "^1.4.0",
    "next": "13.4.4",
    "react": "18.2.0",
    "react-dom": "18.2.0",
    "react-use": "^17.4.0",
    "trpc": "^0.11.3"
  },
  "devDependencies": {
    "@types/node": "20.2.4",
    "@types/react": "18.2.7",
    "@types/react-dom": "18.2.4",
    "eslint": "8.41.0",
    "eslint-config-next": "13.4.4",
    "prisma": "4.9.0",
    "typescript": "5.0.4"
  }
}

記載をした上で下記のコマンドでパッケージをインストールします。

$ npm i

ざっくり入れたっパッケージは下記のようになっています(一部抜粋)

  • @prisma/client
  • @tanstack/react-query"
  • trpc関連一式
  • axios

tsconfig.jsonの下記を追記します。

tsconfig.json
"compilerOptions": {
   "strict": true
}

完成物のディレクトリ構成

最終的なディクレクトリ構成は下記のようになり想定です。概要を掴みやすいように先に紹介しておきます。

.
├── prisma  # Prisma関連の設定やスキーマが格納されているディレクトリ
│   └── [..]
├── src  # アプリケーションのソースコードが格納されているディレクトリ
│   ├── pages  # Next.jsのページコンポーネントが格納されているディレクトリ
│   │   ├── _app.tsx  # アプリケーション全体で共有するコードを含む特別なファイル。ここに`withTRPC()`の高階コンポーネント(HOC)を追加
│   │   ├── api  # APIエンドポイントが格納されているディレクトリ
│   │   │   └── trpc  # tRPCのエンドポイントが格納されているディレクトリ
│   │   │       └── [trpc].ts  # tRPCのHTTPハンドラ
│   │   └── [..]
│   ├── server  # サーバーサイドのコードが格納されているディレクトリ
│   │   ├── routers  # tRPCルーターが格納されているディレクトリ
│   │   │   ├── _app.ts  # メインアプリケーションルーター
│   │   │   ├── post.ts  # サブルーター
│   │   │   └── [..]
│   │   ├── context.ts   # アプリケーションコンテキストを作成
│   │   └── trpc.ts      # プロシージャヘルパー
│   └── utils  # ユーティリティ(汎用的な関数やコンポーネントなど)が格納されているディレクトリ
│       └── trpc.ts  # 型安全なtRPCフック
└── [..]

まず初めにサーバー側(いままでのAPI側)を準備していきます。src/server/配下の処理はサーバー側で使うものと考えてください。

tRPCを使う準備も兼ねて下記の手順で進めていきます。

  1. tRPCの初期化
  2. tRPCとZodライブラリを利用しAPIルートを定義する
  3. appルーターにhelloルーターを追加する
  4. tRPCを使ってNext.jsのAPIハンドラを作成しエクスポートする
  5. tRPCを利用してAPIへの接続を行う設定
  6. app.tsxでtRPCをラップする
  7. 作成した関数をフロント側で呼び出す

tRPCの初期化

src/server/trpc.tsを作成しtRPCのサーバーサイド側を初期化しinitTRPCを作成します。

ここでは単純な「hello world」と表示されるルーターを作成します。

src/server/trpc.ts
import { initTRPC } from '@trpc/server';  // tRPCサーバーの初期化関数をインポート
const t = initTRPC.create();  // tRPCを初期化してインスタンスを作成。ここでは変数名としてtを使用。
export const router = t.router;  // tオブジェクトからルーターを取得しエクスポート
export const procedure = t.procedure;  // tオブジェクトからプロシージャヘルパーを取得しエクスポート

tRPCとZodライブラリを利用しAPIルートを定義する

src/server/api/routers/hello.ts
import { z } from "zod"; // zodライブラリからzをインポート
import { procedure, router } from "../../trpc"; // tRPCのルーターとプロシージャヘルパーをインポート

// APIのルートを定義
export const helloRouter = router({
  // 'hello'という名前のルートを定義
  getHello: procedure
    // 入力となるオブジェクトの型をzodで定義
    .input(
      z.object({
        text: z.string(), // 'text'という名前のプロパティが文字列であることを定義
      })
    )
    // 入力データを元に返却するデータを定義
    .query((opts) => {
      return {
        greeting: `hello ${opts.input.text}`, // 入力の'text'プロパティを使って挨拶文を作成
      };
    }),
});

ここで定義されているhelloルートは、入力としてtextという名前の文字列を受け取り、それを元に挨拶文を作成して返します。

例えば、入力が{ text: "world" }だった場合、返却されるデータは{ greeting: "hello world" }になります。

appルーターにhelloルーターを追加する

先ほど作成したhelloルーターをappルーターに登録します。

src/server/api/routers/_app.ts
import { router } from "../../trpc";
import { helloRouter } from "./hello";

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

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

tRPCを使ってNext.jsのAPIハンドラを作成しエクスポートする

src/pages/api/trpc/[trpc].ts
import * as trpcNext from '@trpc/server/adapters/next';  // tRPCのNext.jsアダプタをインポート
import { appRouter } from '../../../server/routers/_app';  // appRouterをインポート

// APIハンドラをエクスポート
// 詳細は https://trpc.io/docs/server/adapters を参照
export default trpcNext.createNextApiHandler({
  router: appRouter,  // 使用するルーターとしてappRouterを指定
  createContext: () => ({}),  // APIのコンテキストを作成する関数。ここでは空のオブジェクトを返す
});

tRPCを利用してAPIへの接続を行う設定

tRPCのクライアントインスタンスを作成し、その設定を指定しています。

ここでは、サーバのURLを動的に決定し、クライアントの接続先を設定するためのgetBaseUrl関数を定義し、createTRPCNext関数を利用してtRPCのクライアントインスタンスを作成しています。

環境変数っぽい値がありますが、環境変数ファイル(.env)の作成は不要です。

src/utils/trpc.ts

import { httpBatchLink } from '@trpc/client';  // tRPCクライアントのhttpBatchLinkをインポート
import { createTRPCNext } from '@trpc/next';  // tRPCのNext.jsアダプタからcreateTRPCNextをインポート
import type { AppRouter } from '../server/routers/_app';  // AppRouter型をインポート

// ベースURLを取得する関数
function getBaseUrl() {
  if (typeof window !== 'undefined')
    // ブラウザの場合は相対パスを使用
    return '';
  if (process.env.VERCEL_URL)
    // Vercelの場合
    return `https://${process.env.VERCEL_URL}`;
  if (process.env.RENDER_INTERNAL_HOSTNAME)
    // Renderの場合
    return `http://${process.env.RENDER_INTERNAL_HOSTNAME}:${process.env.PORT}`;
  // 上記のいずれにも該当しない場合はlocalhostを仮定
  return `http://localhost:${process.env.PORT ?? 3000}`;
}

// tRPCのクライアントを作成
export const trpc = createTRPCNext<AppRouter>({
  config(opts) {
    return {
      links: [
        httpBatchLink({
          // SSRを使用する場合は、サーバの完全なURLが必要
          // 詳細は https://trpc.io/docs/ssr を参照
          url: `${getBaseUrl()}/api/trpc`,
          // 必要なHTTPヘッダーをここに設定可能
          async headers() {
            return {
              // authorization: getAuthCookie(),
            };
          },
        }),
      ],
    };
  },
  // SSRの使用有無を設定
  // 詳細は https://trpc.io/docs/ssr を参照
  ssr: false,
});

app.tsxでtRPCをラップする

src/pages/_app.tsx
import type { AppType } from "next/app";
import { trpc } from "../utils/trpc";
const MyApp: AppType = ({ Component, pageProps }) => {
  return <Component {...pageProps} />;
};
export default trpc.withTRPC(MyApp);

作成した関数の呼び出し

準備が完了したのでサーバーで開発したhelloRouterからgetHelloを呼び出してフロント側で表示させます。

src/pages/hello.tsx
import { trpc } from "../utils/trpc";

export default function IndexPage() {
  const hello = trpc.hello.getHello.useQuery({ text: "client" });
  if (!hello.data) {
    return <div>Loading...</div>;
  }
  return (
    <div>
      <p>{hello.data.greeting}</p>
    </div>
  );
}

ローカル環境を起動すると下記のように表示されることが確認できます。

useQuery({ text: "client" })React Queryというデータフェッチングライブラリの一部で、その機能をtRPCが統合しています。

useQueryは指定したエンドポイント(この場合は getHello)に対してクエリを行い、その結果を返します。

このクエリのパラメータとして{ text: "client" }を渡しています。これはgetHelloエンドポイントが要求するデータ形式に合わせたものです。

Zodの確認

zodを下記のように定義しているので、呼び出し側の引数にstring型以外を入れるとエラーが出てきます。

src/server/api/routers/hello.ts
  getHello: procedure
    // 入力となるオブジェクトの型をzodで定義
    .input(
      z.object({
        text: z.string(), // 'text'という名前のプロパティが文字列であることを定義
      })
    )
src/pages/hello.tsx
import { trpc } from "../utils/trpc";

export default function IndexPage() {
  // エラーになる
  const hello = trpc.hello.getHello.useQuery({ text: 1 });
  if (!hello.data) {
    return <div>Loading...</div>;
  }
  return (
    <div>
      <p>{hello.data.greeting}</p>
    </div>
  );
}

このようにtRPCを利用することでフロントとサーバーでデータの整合性及び型安全を保つこともできます。

次は実際にTODOアプリを開発していきます

tRPCを使ったAPI結合

成果物

  • 一覧機能、詳細取得、新規登録、更新、削除機能(CRUD)をもつTODOアプリを作成する

下記のようなアプリが完成する想定です。(CSSは当ててないです..)

こちらのアプリをtRPCとZodを利用して開発していきます。

tRPCとZodの環境構築及び設定の準備は先ほどの章で説明しているので割愛します。

  1. データベースの準備
  2. Prismaにモデルを定義
  3. 新規登録・更新用のデータのバリデーションを定義
  4. サーバー側にCRUDのロジックとAPIルートを作成
  5. 作成たAPIルートを登録
  6. 一覧取得の呼び出し
  7. 新規作成・削除の呼び出し
  8. 詳細・更新の呼び出し

dockerファイルの作成

DBを作成するためにドッカーファイルをルート直下に作成します。

docker-compose.yml
version: "3.8" # 使用するDocker Composeのバージョンを指定します。

services:
  dev-postgres:
    image: postgres:14.4-alpine # PostgreSQLのバージョン14.4を基にしたAlpine Linuxイメージを使用します。
    ports:
      - 5434:5432 # ホストマシンの5434ポートとコンテナの5432ポートを紐づけます。これにより、ホストマシンから5434ポートを介してPostgreSQLにアクセスできます。
    environment:
      POSTGRES_USER: trpc # PostgreSQLのユーザー名を指定します。
      POSTGRES_PASSWORD: trpc # PostgreSQLのパスワードを指定します。
      POSTGRES_DB: trpc # PostgreSQLのデータベース名を指定します。
    restart: always # コンテナが停止したときに常に再起動します。これにより、エラーやサーバーの再起動後でも自動的にコンテナが起動します。
    networks:
      - lesson # このサービスを'lesson'という名前のネットワークに接続します。

networks:
  lesson: # 'lesson'という名前のネットワークを作成します。上記のサービスがこのネットワークに接続します。

Dockerを起動します。

# dockerの起動
$ docker compose up -d

# DBをリセット (必要な場合だけ)
$ docker compose rm -s -f -v

Prismaにモデルを定義

Prismaを使い始める際に下記のコマンドを実行して、スキーマを定義するファイルを生成します。

$ npx prisma init 

下記のようなディレクトリ構造化にスキーマファイルが作成されます。

.
├── node_modules
└── prisma
       └── shema.prisma

TODO用のスキーマを定義します。
これでidとtitleとbodyカラムを持つテーブルが作成する準備ができました。

prisma/schema.prisma
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model Todo {
  id        Int      @id @default(autoincrement())
  title     String
  body String
}

またdocker-compose.ymlに記載した情報を.envファイルを作成します。

ユーザー名やパスワードを「tprc」と定義したので下記のようなDATABASE_URLが作成されます。

.env
# Environment variables declared in this file are automatically made available to Prisma.
# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema

# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
# See the documentation for all the connection string options: https://pris.ly/d/connection-strings

DATABASE_URL="postgres://trpc:trpc@localhost:5434/trpc?schema=public"

この記述でスキーマファイルの下記の部分の読み込み準備ができました。

prisma/schema.prisma
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

下記のコマンドでスキーマファイルに書いた内容をDBに反映させます。

$ npx prisma migrate dev

DBができているかをローカル環境で確認します。

$ npx prisma studio   

http://localhost:5555/ にアクセスし下記が表示されればOKです。

新規登録・更新用のデータのバリデーションを定義

Zodを利用してTODOの新規登録及び更新時のバリデーションとエラーメッセージを定義していきます。

  • titleは1文字以上10文字以下
  • bodyは1文字以上50文字以下
src/server/types/todo.ts
import { z } from "zod";

// 新規作成用
export const createInput = z.object({
  title: z
    .string()
    .min(1, "todo must be at least 1 letter")
    .max(10, "todo must be 10 letters or less"),
  body: z
    .string()
    .min(1, "todo must be at least 1 letter")
    .max(50, "todo must be 50 letters or less"),
});

// 更新用
export const updateInput = z.object({
  id: z.number(),
  title: z
    .string()
    .min(1, "todo must be at least 1 letter")
    .max(50, "todo must be 10 letters or less"),
  body: z
    .string()
    .min(1, "todo must be at least 1 letter")
    .max(50, "todo must be 50 letters or less"),
});

サーバー側にCRUDのロジックとAPIルートを作成

ここからPrismaを利用して実際にDBからデータを取得、登録、削除、更新をするロジックを記載していきます。

まずは一覧取得と詳細取得のロジックを書いてきます。

src/server/api/routers/todo.ts
import { PrismaClient } from "@prisma/client";
import { z } from "zod";

import { procedure, router } from "../../trpc";
import { createInput, updateInput } from "../../types/todo";

// 新しいPrismaClientインスタンスを作成します。
const prisma = new PrismaClient();

// 新しいrouterを作成します。
export const todoRouter = router({
  
  // 全てのTODOを取得するクエリです。
  getTodos: procedure.query(async () => {
    // 全てのTODOをデータベースから取得します。
    const todos = await prisma.todo.findMany();
    
    // 取得したTODOを返します。
    return todos;
  }),
  
  // 特定のIDのTODOを取得するクエリです。
  getTodoById: procedure
    .input(
      // 入力スキーマを指定します。IDは数値である必要があります。
      z.object({
        id: z.number(),
      })
    )
    .query(async ({ input }) => {
      // 指定されたIDのTODOをデータベースから取得します。
      const todo = await prisma.todo.findUnique({
        where: { id: input.id },
      });
      
      // TODOが見つからない場合、エラーをスローします。
      if (!todo) {
        throw new Error("Todo not found");
      }
      
      // 取得したTODOを返します。
      return todo;
    }),
});

次に新規作成のロジックを追加してきます。

src/server/api/routers/todo.ts
// 新しいTODOを作成するミューテーションです。
createTodo: procedure.input(createInput).mutation(async ({ input }) => {
  
  // 新しいTODOをデータベースに作成します。タイトルとボディを入力から取得します。
  const todo = await prisma.todo.create({
    data: {
      title: input.title,
      body: input.body,
    },
  });

  // 作成したTODOを返します。
  return todo;
}),

削除と更新のロジックを追加していきます。

src/server/api/routers/todo.ts
  updateTodo: procedure.input(updateInput).mutation(async ({ input }) => {
    const { id, title, body } = input;
    const todo = await prisma.todo.update({
      where: { id },
      data: { title, body },
    });
    return todo;
  }),
  deleteTodo: procedure
    .input(
      z.object({
        id: z.number(),
      })
    )
    .mutation(async ({ input }) => {
      await prisma.todo.delete({
        where: { id: input.id },
      });
    }),

完成は下記のようになります。

src/server/api/routers/todo.ts
import { PrismaClient } from "@prisma/client";
import { z } from "zod";

import { procedure, router } from "../../trpc";
import { createInput, updateInput } from "../../types/todo";
const prisma = new PrismaClient();

export const todoRouter = router({
  getTodos: procedure.query(async () => {
    const todos = await prisma.todo.findMany();
    return todos;
  }),
  getTodoById: procedure
    .input(
      z.object({
        id: z.number(),
      })
    )
    .query(async ({ input }) => {
      const todo = await prisma.todo.findUnique({
        where: { id: input.id },
      });
      if (!todo) {
        throw new Error("Todo not found");
      }
      return todo;
    }),
  createTodo: procedure.input(createInput).mutation(async ({ input }) => {
    const todo = await prisma.todo.create({
      data: {
        title: input.title,
        body: input.body,
      },
    });
    return todo;
  }),
  updateTodo: procedure.input(updateInput).mutation(async ({ input }) => {
    const { id, title, body } = input;
    const todo = await prisma.todo.update({
      where: { id },
      data: { title, body },
    });
    return todo;
  }),
  deleteTodo: procedure
    .input(
      z.object({
        id: z.number(),
      })
    )
    .mutation(async ({ input }) => {
      await prisma.todo.delete({
        where: { id: input.id },
      });
    }),
});

作成たAPIルートを登録

先ほど作成したロジックをフロント側でも呼び出せるように_app.tsにtodoルーターを追加します。

src/server/api/routers/_app.ts
import { router } from "../../trpc";
import { helloRouter } from "./hello";
import { todoRouter } from "./todo";

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

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

これでサーバー側のTODOの準備は完了しました。

一覧取得の呼び出し

src/pages/todo/index.tsx
import { trpc } from "../../utils/trpc";
import Link from "next/link";

export default function Page() {
  const { data } = trpc.todo.getTodos.useQuery();

  return (
    <div>
      <ul>
        {data &&
          data.map((todo) => (
            <div key={todo.id}>
              <Link href={`todo/${todo.id}`}>
                <li>{todo.title}</li>
              </Link>
            </div>
          ))}
      </ul>
    </div>
  );
}

まだDBにデータが入っていないので何も表示されません。

http://localhost:5555 にアクセスし手動でデータを入れます。

下記からデータを手入力で入れることができます。

ローカルホストを開くと下記のようにデータが表示されていることが確認できます。

新規作成・削除の呼び出し

新規作成と削除用のUI作成とサーバー側で作成した関数を呼び出します。

src/pages/todo/index.tsx
import { trpc } from "../../utils/trpc";
import Link from "next/link";
import { ChangeEvent, useState } from "react";

export default function Page() {

   // getTodosエンドポイントからデータを取得
  const { data } = trpc.todo.getTodos.useQuery();

  // createTodoエンドポイントに対するmutationを生成
  const createTodoMutation = trpc.todo.createTodo.useMutation();

  // deleteTodoエンドポイントに対するmutationを生成
  const deleteTodoMutation = trpc.todo.deleteTodo.useMutation();

  // タイトルと本文を保持するためのstateを作成
  const [title, setTitle] = useState<string>("");
  const [body, setBody] = useState<string>("");

  // データがまだ取得できていない場合、何も表示しない
  if (!data) {
    return;
  }

  // 新規ToDoを作成する処理
  const handleCreateTodo = async () => {
    try {
      // タイトルと本文が存在する場合のみ新規ToDoを作成
      if (title && body) {
        // createTodoエンドポイントを呼び出して新規ToDoを作成
        await createTodoMutation.mutateAsync({ title, body });

        // ToDo作成後、タイトルと本文をクリア
        setTitle("");
        setBody("");

        // アラートで新規登録を通知
        alert("新規登録しました");
      }
    } catch (error) {
      // エラーハンドリング
      console.error(error);
    }
  };

  // ToDoを削除する処理
  const handleDeleteTodo = async (id: number) => {
    try {
      // deleteTodoエンドポイントを呼び出して指定したIDのToDoを削除
      await deleteTodoMutation.mutateAsync({ id });

      // アラートで削除を通知
      alert("削除しました");
    } catch (error) {
      // エラーハンドリング
      console.error(error);
    }
  };

  return (
    <div>
      <ul>
        {data.map((todo) => (
          <div key={todo.id}>
            <Link href={`todo/${todo.id}`}>
              <li>{todo.title}</li>
            </Link>
            <button
              onClick={() => {
                handleDeleteTodo(todo.id);
              }}
            >
              削除
            </button>
          </div>
        ))}
      </ul>
      <div className="create-form">
        <p>新規作成</p>
        <div className="title">
          <label>タイトル</label>
          <input
            type="text"
            onChange={(e: ChangeEvent<HTMLInputElement>) => {
              setTitle(e.target.value);
            }}
            value={title}
          />
        </div>
        <div className="body">
          <label>本文</label>
          <input
            type="text"
            onChange={(e: ChangeEvent<HTMLInputElement>) => {
              setBody(e.target.value);
            }}
            value={body}
          />
        </div>
        <button onClick={handleCreateTodo}>登録</button>
      </div>
    </div>
  );
}

一旦すでに作成している値を削除ボタンをクリックして削除します。

その上で新規登録を行うと、その場でUIにデータが反映されることを確認できます。

詳細・更新

詳細と更新用のページ及びロジックを作成します。

src/pages/todo/[id].tsx
import { trpc } from "../../utils/trpc";

import { useRouter } from "next/router";
import { ChangeEvent, useState } from "react";

export default function Page() {
  const router = useRouter();
  const updateMutation = trpc.todo.updateTodo.useMutation();
  const [title, setTitle] = useState<string>("");
  const [body, setBody] = useState<string>("");
  const id = Number(router.query.id);

  const { data } = trpc.todo.getTodoById.useQuery({
    id,
  });
  if (!data) {
    return;
  }

  const handleUpdateTodo = async () => {
    try {
      if (title && body) {
        await updateMutation.mutateAsync({ id, title, body });
        setTitle("");
        setBody("");
        alert("更新しました");
      }
    } catch (error) {
      console.error(error);
    }
  };

  return (
    <>
      <div>
        <p>タイトル: {data.title}</p>
        <p>本文: {data.body}</p>
      </div>
      <div className="update-container">
        <p>更新</p>
        <div className="title">
          <label>タイトル</label>
          <input
            type="text"
            onChange={(e: ChangeEvent<HTMLInputElement>) => {
              setTitle(e.target.value);
            }}
            value={title}
          />
        </div>
        <div className="body">
          <label>本文</label>
          <input
            type="text"
            onChange={(e: ChangeEvent<HTMLInputElement>) => {
              setBody(e.target.value);
            }}
            value={body}
          />
        </div>
        <button onClick={handleUpdateTodo}>更新</button>
      </div>
    </>
  );
}

詳細ページにアクセスし、更新イベントを呼び出すとデータが更新されるのが確認できます。

tRPCを実装する手順のまとめ

今までの流れを一度整理します。

  1. Prismaのスキーマにカラムを定義しマイグレーションを行う
  2. 新規登録や更新などがある場合はsrc/server/typesにZodでバリデーションを登録
  3. src/server/api/routers配下にPrismaとCRUDロジックを記述する
  4. 3で作成したルーターをsrc/server/api/routers/_app.tsに登録
  5. フロントでtrpc.todo.getTodos.useQuery()な形でtrpcを呼び出す

これによってサーバー側とデータの整合性及び型安全をとることができます。

最後に

今回はtRPCとZodについて解説をしました。ぜひ本記事を通して実践的なtRPCの開発をやっていただければと思います。

他にも色々な記事を書いているので合わせて読んでいただけると嬉しいです。

https://zenn.dev/sutamac/articles/5a262f0096176a

https://zenn.dev/sutamac/articles/27246dfe1b5a8e

https://zenn.dev/sutamac/articles/7e864fb9e30d70

Discussion