アプリ開発をしながらtRPCとZodを学ぶ
はじめに
今回は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についてより詳しい解説を知りたい人は下記のドキュメントを読んでみることをおすすめします。
今回は「tRPCはT3 Stackでも採用されている技術なのか〜」程度の理解で大丈夫です。
tRPC
tRPCはスキーマやコード生成なしに、完全に型安全なAPIを簡単に構築し使用することができる技術です。
昨今のフロントエンドとサーバーサイド(API)の繋ぎ込みにおいては、静的に型付けを行い共有するような方法が必要とされています。
その中でTypeScriptを利用した型安全なAPIを構築するためのシンプルな技術がtRPCとなっています。
簡単に言うと、サーバー側(API側)で定義したインターフェースをそのままクライアント(NextやTypeScritp等)で取り込んでつなぎ込みができます。
言葉だけだと少しわかりずらいので、この後の章で具体的なコードを紹介します。
Zod
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
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結合は下記の手順で行うことが多いかと思います。(あくまで一例)
- サーバー側でAPIの実装
- OpenAPIドキュメントを生成しフロントへ共有
- フロントはOpenAPIドキュメントからモックサーバーの立ち上げ
- モックサーバーのエンドポイントに対してAPI結合をしUIを表示
説明だけだとわかりずらいので具体的なコードを見ていきます。
なおこの部分は自分の手元でやらなくても大丈夫です。なんとなく「確かにいつもこんな感じでAPI結合しているな〜」程度で理解していただければと思います。
簡易的なTODOアプリの例で見ていきます。1のサーバー側のAPIは実装済みの想定で2から見ていきます。
OpenAPIドキュメントを生成しフロントへ共有
下記のようなAPIドキュメントがサーバー側から共有されたとします。
共有された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です。
拡張機能を使って開くと下記のような表示がされます。
フロントはOpenAPIドキュメントからモックサーバーの立ち上げ
先ほど共有されたOpenAPIをモックサーバーで立ち上げます。
詳しい立ち上げ方は下記の記事を参考にしてみてください。
一応結論だけ、Next.jsのプロジェクトを環境構築し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とかでの確認も可)
これで上記のエンドポイントを実際にNext.js側でaxios
等を利用し叩くことによってデータをUIとして表示できる準備ができました。
モックサーバーのエンドポイントに対してAPI結合をしUIを表示
useEffect(useAcync)
を利用して下記のようにAPIを結合することができます。
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アプリを作る前にチュートリアルとして、公式ドキュメントに載っている簡単な例をやっていきます。(やったことがある人は飛ばしてしまって大丈夫です)
環境構築
まずはざっくり環境構築していきます。下記のコマンドでnext.jsとtypescriptの環境を立ち上げます。こちらの環境は後の簡易TODOアプリでも利用する環境になっています。
$ npx create-next-app trpc-project --ts
今回はDBはDockerを利用するのでルートディレクトリに下記のファイルを作成します。
具体的なDBの起動は次の章で解説をします。チュートリアルでは使いません。
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
を下記にしてください。
{
"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の下記を追記します。
"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を使う準備も兼ねて下記の手順で進めていきます。
- tRPCの初期化
- tRPCとZodライブラリを利用しAPIルートを定義する
- appルーターにhelloルーターを追加する
- tRPCを使ってNext.jsのAPIハンドラを作成しエクスポートする
- tRPCを利用してAPIへの接続を行う設定
- app.tsxでtRPCをラップする
- 作成した関数をフロント側で呼び出す
tRPCの初期化
src/server/trpc.ts
を作成しtRPCのサーバーサイド側を初期化しinitTRPCを作成します。
ここでは単純な「hello world」と表示されるルーターを作成します。
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ルートを定義する
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ルーターに登録します。
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ハンドラを作成しエクスポートする
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)の作成は不要です。
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をラップする
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
を呼び出してフロント側で表示させます。
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型以外を入れるとエラーが出てきます。
getHello: procedure
// 入力となるオブジェクトの型をzodで定義
.input(
z.object({
text: z.string(), // 'text'という名前のプロパティが文字列であることを定義
})
)
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の環境構築及び設定の準備は先ほどの章で説明しているので割愛します。
- データベースの準備
- Prismaにモデルを定義
- 新規登録・更新用のデータのバリデーションを定義
- サーバー側にCRUDのロジックとAPIルートを作成
- 作成たAPIルートを登録
- 一覧取得の呼び出し
- 新規作成・削除の呼び出し
- 詳細・更新の呼び出し
dockerファイルの作成
DBを作成するためにドッカーファイルをルート直下に作成します。
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カラムを持つテーブルが作成する準備ができました。
// 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
が作成されます。
# 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"
この記述でスキーマファイルの下記の部分の読み込み準備ができました。
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文字以下
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からデータを取得、登録、削除、更新をするロジックを記載していきます。
まずは一覧取得と詳細取得のロジックを書いてきます。
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;
}),
});
次に新規作成のロジックを追加してきます。
// 新しい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;
}),
削除と更新のロジックを追加していきます。
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 },
});
}),
完成は下記のようになります。
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ルーターを追加します。
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の準備は完了しました。
一覧取得の呼び出し
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作成とサーバー側で作成した関数を呼び出します。
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にデータが反映されることを確認できます。
詳細・更新
詳細と更新用のページ及びロジックを作成します。
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を実装する手順のまとめ
今までの流れを一度整理します。
- Prismaのスキーマにカラムを定義しマイグレーションを行う
- 新規登録や更新などがある場合は
src/server/types
にZodでバリデーションを登録 -
src/server/api/routers
配下にPrismaとCRUDロジックを記述する - 3で作成したルーターを
src/server/api/routers/_app.ts
に登録 - フロントで
trpc.todo.getTodos.useQuery()
な形でtrpcを呼び出す
これによってサーバー側とデータの整合性及び型安全をとることができます。
最後に
今回はtRPCとZodについて解説をしました。ぜひ本記事を通して実践的なtRPCの開発をやっていただければと思います。
他にも色々な記事を書いているので合わせて読んでいただけると嬉しいです。
Discussion