43
18

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

tRPC で TODO アプリを作る

Last updated at Posted at 2022-09-05

弊社では、現在プロダクトの技術スタックに tRPC を導入することを検討しています。
その検討で得た知見をもとに、本記事では tRPC を使って簡単な React TODO アプリを作り方をお伝えしたいと思います。
tRPC は日本語記事が少ないので、この記事を参考に tRPC を試してみていただけると嬉しいです。

今回のサンプルコードはこちら

tRPC とは?

公式ページ

TypeScript を使って型安全な Web API 通信を簡単に実装できる BFF フレームワークです。
直近で GitHub の star 数が急上昇しており、フロントエンド界隈ではワールドワイドで盛り上がっているプロジェクトです (以下 GitHub ページより)。
star-history-202297.png
類似の BFF フレームワークとしては GraphQL が有名ですが、tRPC でも GraphQL とほとんど同様のことができます。
GraphQL と比較した場合の tRPC 特徴はざっくり以下のような感じです。

  • TypeScript 限定 (Web や React Native 等での利用が想定されている)
  • 軽量 (公式ページによると 🍃 Light - tRPC has zero deps and a tiny client-side footprint.)
  • 学習コストが低い

特に学習コストが GraphQL に比べて低いという点は tRPC の大きなアドバンテージです。
GraphQL や gRPC ではクライアント / サーバー間の API 通信を行うために独自のクエリで API を定義する必要がありますが、tRPC ではそのようなことは不要です。そのためサクッと実装することができ、開発体験が非常に良いです。
ではどのようにしてクライアント・サーバー間で型安全な API 通信を実現しているかというと、具体的には tRPC ではサーバー側で実装した router モジュールの型をエクスポートして、クライアント側で利用します。

// サーバー側のモジュール
// (公式ページからの引用)
import * as trpc from '@trpc/server';
const appRouter = trpc.router();

// only export *type signature* of router!
// to avoid accidentally importing your API
// into client-side code
export type AppRouter = typeof appRouter;  // この型をクライアント側で使用する

また公式ページのドキュメント量も多くないので、理解しやすいです。

以降で簡単な React TODO アプリを作って、実際の流れを説明したいと思います。

TODO アプリを作る

今回はシンプルに、tRPC サーバー (Express) と API 通信を行って TODO データのやり取りを行う React TODO アプリ (tRPC クライアント) を作ります。

tRPC サーバーの実装

まず tRPC ライブラリをインストールします。

# zod は必須ではないですが、公式ページでも使用されているバリデーション ライブラリです
yarn add @trpc/server zod

tRPC では router を生成し、router の query/mutation メソッドに API を実装します。
コードの内容をシンプルにするため今回は TODO の一覧を取得する query と、TODO を追加する mutation エンドポイントの 2 つだけを実装します。

/server/router.ts
import trpc from "@trpc/server";

export const appRouter = trpc
  .router()  // ルーターの生成
  .query("getTodoList", {  // エンドポイント名: getTodoList
    // resolve() 内に実装を書いていく (todoService の実装は割愛)
    async resolve(): Promise<Todo[]> {  
      return await todoService.getAll();
    },
  })
  .mutation("addTodo", {  // エンドポイント名: addTodo
    // input 内にユーザーインプットのバリデーションを書いていく
    // この例では zod を使って { text: string} をバリデーション
    input: z.object({
      text: z.string(),
    }),
    async resolve(req): Promise<Todo> {
      // 上で定義した input が req に格納されている
      // 不正な input であった場合は、この手前で HTTP400 エラーが返される
      const { text } = req.input;  
      const id = uuid();
      return await todoService.addTodo(id, text);
    },
  })

export type AppRouter = typeof appRouter;

以上で router の実装は完了です。
tRPC は Express サーバーまたは Next.js に middleware として取り入れることが可能なので、既存の Express/Next.js プロジェクトに tRPC を追加することも容易に可能です。
Express に tRPC を追加するためには、createExpressMiddleware 関数を使用します。
このとき、リクエスト発生時にコンテキストを生成する createContext 関数も引数として渡す必要があるので、一緒に実装します。
(今回はコンテキストは使わないので、空の関数とします。コンテキストに関してはまた別の投稿で!)

server/index.ts
import { createExpressMiddleware } from "@trpc/server/adapters/express";
import { appRouter as router } from "./router";

const app = express();

// リクエストごとにコンテキストを生成する関数
const createContext = ({
  req,
  res,
}: trpcExpress.CreateExpressContextOptions) => ({});

// middlware として tRPC router を追加
// path は任意ですが、公式と合わせて /trpc とします。
app.use(
  '/trpc',
  createExpressMiddleware({ router, createContext })
);

app.listen(4000);

以上でサーバー側の実装は終わりです。

tRPC クライアント (React) の実装

まずはクライアント用ライブラリをインストールします。
tRPC の React ライブラリは TanStack Query (React Query) を内部で使用するため、一緒にインストールします。
注意点としてはクライアントにも @trpc/server が必要です。これがないと query や mutation を行った際に取得したデータの型が解決できません。

yarn add @trpc/client @trpc/server @trpc/react react-query@3

クライアント側では createReactQueryHooks 関数で hook を作成します。
この際にサーバー側でエクスポートした router の型をジェネリクスに含めることで、vscode などのエディターで補完が聞いて簡単に実装を書くことができます。

client/trpc.ts
import { createReactQueryHooks } from "@trpc/react";
// サーバー側の router.ts からエクスポートされた AppRouter をインポート
import type { AppRouter } from "../../../server/router";

export const trpc = createReactQueryHooks<AppRouter>();

生成した hook を使用してまずは App.tsx 上でプロバイダーの設定を行います。

client/App.tsx
import { useState } from "react";
import { QueryClientProvider, QueryClient } from "react-query";

import { trpc } from "./trpc";
import { Form } from "./Form";
import { TodoList } from "./TodoList";

import "./App.css";

const url = "http://localhost:3000/trpc";
const client = new QueryClient();

function App() {
  const [trpcClient] = useState(() => trpc.createClient({ url }));

  return (
    <trpc.Provider queryClient={client} client={trpcClient}>
      <QueryClientProvider client={client}>
        <div>
          <TodoList />
          <Form />
        </div>
      </QueryClientProvider>
    </trpc.Provider>
  );
}

export default App;

これで tRPC クライアントを使用する準備ができました!
hook の useQuery 関数を使用して、TODO の一覧を取得するコンポーネントを作成します。

client/TodoList.tsx
import { trpc } from "./trpc";

export const TodoList = () => {
  const { data, isFetched } = trpc.useQuery(["getTodoList"]);

  if (!isFetched) return <>loading</>;

  return (
    <div>
      <span>todo list</span>
      <ul>
        {data?.map((todo) => (
          <li key={todo.id}>{todo.text}</li>
        ))}
      </ul>
    </div>
  );
};

hook の useMutation 関数を使用して TODO を追加するコンポーネントも作成します。

Form.tsx
import { useRef } from "react";
import { trpc } from "./trpc";

export function Form() {
  const mutation = trpc.useMutation("addTodo");
  const inputRef = useRef<HTMLInputElement>(null);

  const handleClick = () => {
    if (inputRef.current) {
      mutation.mutate({ text: inputRef.current.value });
    }
  };

  return (
    <div>
      <input ref={inputRef} type="text" placeholder="add something" />
      <button onClick={handleClick}>ADD</button>
    </div>
  );
}

これで完成です!
しかしながらこれだと TODO を追加後に、事前に取得していた TODO リストが動的に更新されないという問題があります。
対処方法として、mutate に onSuccess コールバック関数を渡すことで、mutate 成功時にその関数を実行することができるので、その中で invalidateQueries を呼び出し、事前に取得していた "getTodoList" クエリのキャッシュを invalidate します。
(subscription を使用するなど、もっと良い方法があると思いますが、今回はこの方法で。)

Form.tsx
import { useRef } from "react";
import { trpc } from "./trpc";

export function Form() {
  const mutation = trpc.useMutation("addTodo");
  const inputRef = useRef<HTMLInputElement>(null);
  const utils = trpc.useContext();

  const handleClick = () => {
    if (inputRef.current) {
      mutation.mutate(
        { text: inputRef.current.value },
        { onSuccess: () => utils.invalidateQueries("getTodoList") }
      );
    }
  };

  return (
    <div>
      <input ref={inputRef} type="text" placeholder="add something" />
      <button onClick={handleClick}>ADD</button>
    </div>
  );
}

改めて…これで完成です!

次回は、tRPC のもう少し細かい機能に触れたいと思います。

また弊社ではライブラリの作者である daishi さんと共に、React アプリの micro state management に jotai を導入している真っ只中です。
冒頭の経緯もあり、この度 jotai と tRPC を React アプリで使用するためのライブラリ jotai-trpc が爆誕しました!
ので、どこかのタイミングで jota-trpc についても投稿できればと思います。

最後まで読んでいただきありがとうございました :bow:

43
18
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
43
18

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?