🍣

T3 stackを使ってモダンなWeb開発を行う 〜tRPC編〜

2023/10/25に公開

0.はじめに

T3 Stack は、モダンな Web 開発を行うためのフルスタックフレームワークで、以下の 6 つの技術スタックを組み合わせています:

  1. Next.js: フロントエンドの Web アプリケーション開発において、高速かつ効率的な React フレームワークです。

  2. tRPC: サーバーとクライアント間の型安全な通信を提供し、API の定義と使用を簡素化します。

  3. Tailwind CSS: カスタマイズ可能なスタイリングのための CSS フレームワークで、設計のシンプルさと拡張性を兼ね備えています。

  4. TypeScript: 型安全性を提供し、コードの品質向上と開発効率の向上に貢献します。

  5. Prisma: データベースアクセスと ORM(Object-Relational Mapping)を簡素化し、型安全なデータベース操作を実現します。

  6. NextAuth.js: 認証とセッション管理をサポートするライブラリで、ユーザーアカウントの認証を簡単に実装できます。

T3 Stack は、シンプルな設計とモジュール化された構造に基づいており、開発効率を向上させ、コードの品質を高めることを目指しています。型安全性が保証されているため、開発者は安心してコードを記述できます。このフレームワークは、フロントエンド開発の効率向上と品質向上を追求する開発者にとって理想的な選択肢の一つです。

この記事では、実際に T3 stack を使って、アプリケーションを開発していきます。

今回は、tRPC を使用して フロントエンドからデータベース に接続し、CRUD 操作を行なっていきます。

tRPC について

tRPC(Typed RPC)は、型安全な方法でクライアントとサーバー間でリモートプロシージャコール(RPC)を行うためのライブラリです。tRPC を使用すると、クライアントとサーバーの間でデータの取得や操作に関する型情報を共有し、型安全性を確保できます。

実際の使用において、tRPC は通常、バックエンドフレームワーク(例:Express、Fastify、Next.js)と統合して使用されます。また、tRPC は他のライブラリやツールと併用され、例えば useQuery や zod などと連携します。バックエンドフレームワークとの統合や他のライブラリとの連携は、具体的な実装において重要な役割を果たします。

公式ドキュメント

1.サーバー側の設定

tRPC は、サーバーサイドの情報を基にしてクライアントサイドにフィードバックする仕組みです。そのため、サーバーサイドの構築が重要になってきます。

まずは、サーバー側の設定を行なっていきます。

ルーターの設定

routerの設定を行なっていきます。API のエンドポイントを作る感じですね。

下記ファイルを追加します。なお、バリデーションに使っているzodについては、こちらの記事を参考にしてください。

src/server/api/routers/user.ts
import { z } from "zod";
import { createTRPCRouter, publicProcedure } from "@/server/api/trpc";

export const userRouter = createTRPCRouter({
  read: publicProcedure.query(({ ctx }) => {
    return ctx.db.user.findMany();
  }),

  create: publicProcedure
    .input(z.object({ name: z.string(), email: z.string(), image: z.string() }))
    .mutation(async ({ ctx, input }) => {
      const result = await ctx.db.user.create({
        data: input,
      });
      return result;
    }),

  update: publicProcedure
    .input(
      z.object({
        id: z.string(),
        name: z.string(),
        email: z.string(),
        image: z.string(),
      }),
    )
    .mutation(async ({ ctx, input }) => {
      const { id, name, email, image } = input;

      const result = await ctx.db.user.update({
        where: {
          id,
        },
        data: {
          name,
          email,
          image,
        },
      });
      return result;
    }),

  delete: publicProcedure
    .input(z.object({ id: z.string() }))
    .mutation(async ({ ctx, input }) => {
      const result = await ctx.db.user.delete({
        where: { ...input },
      });
      return result;
    }),

  upsert: publicProcedure
    .input(
      z.object({
        name: z.string(),
        email: z.string(),
        image: z.string(),
      }),
    )
    .mutation(async ({ ctx, input }) => {
      const { name, email, image } = input;

      const result = await ctx.db.user.upsert({
        where: {
          email,
        },
        create: {
          name,
          email,
          image,
        },
        update: {
          name,
          email,
          image,
        },
      });
      return result;
    }),
});

upsertは、データがあればupdateを行い、データがなければcreateを行う優れものです。

次に、下記の修正を行います。

src/server/api/root.ts
+ import { userRouter } from "@/server/api/routers/user";

export const appRouter = createTRPCRouter({
  example: exampleRouter,
+   user: userRouter,
});

2.クライアント側の設定

せっかくなので今回からは、nextjs にも触れていきます。

Front でテスト的に呼び出し

まずは、user データを呼び出してみましょう。フロントにアクセスした際に、コンソールログにユーザーデータが表示されるように下記のように追記します。

src/pages/index.tsx
export default function Home() {
  const hello = api.example.hello.useQuery({ text: "from tRPC" });
+  const { data: users } = api.user.read.useQuery();
+  console.log(users);

  return (...)
}
npm run dev

でアクセスすると、コンソールログにユーザーデータ(今はまだ登録されていないと思うので[])が表示されると思います。

UserForm

次にユーザー登録ができるフォームを作っていきましょう。

まずは、バックエンドと通信を行う際の共通関数を定義するカスタムフックを作っていきたいと思います。

create,delete,update,upsertはどれもpostメソッドに該当するため、useMutationを使っています。また、操作が成功した場合(onSuccess)と失敗した場合(onError)の処理を統一しています。

src/hooks/useHandlerUser.ts
import { type AppRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { type TRPCClientErrorLike } from "@trpc/client";

export const useHandlerUser = () => {
  const utils = api.useContext();

  const commonMutationHandler = {
    onSuccess: () => utils.user.read.invalidate(),
    onError: (error: TRPCClientErrorLike<AppRouter>) => alert(error),
  };

  const createUserHandler = api.user.create.useMutation(commonMutationHandler);
  const deleteUserHandler = api.user.delete.useMutation(commonMutationHandler);
  const updateUserHandler = api.user.update.useMutation(commonMutationHandler);
  const upsertUserHandler = api.user.upsert.useMutation(commonMutationHandler);

  return [
    {
      createUserHandler,
      deleteUserHandler,
      updateUserHandler,
      upsertUserHandler,
    },
  ] as const;
};

次にユーザーフォームを作っていきます。コードが長くなるので、ポイントだけ抜き出しますね。

// config react-hook-form
const {
  register,
  handleSubmit,
  formState: { errors },
} = useForm<PostUser>({
  resolver: zodResolver(PostUserSchema),
  defaultValues: user,
});

ここでは、フォームの入力値に対して、zod を使ってバリデーションを行っています。バリデーションの内容を別ファイルに取り出せる点が非常に有益だと捉えています。取り出した部分は、下記の通りとなっていて、バリデーションのみに注力することができます。

src/utils/validation.ts
import { z } from "zod";

const necessaryMessage = "入力してください";

export const PostUserSchema = z.object({
  name: z.string().min(1, { message: necessaryMessage }),
  email: z
    .string()
    .min(1, { message: necessaryMessage })
    .email({ message: "メールアドレスを入力してください" }),
  image: z.string().min(1, { message: "選択してください" }),
});

export type PostUser = z.infer<typeof PostUserSchema>;

次に、submit した時の挙動ですね。setIsEditは、edit 状態を保存する関数です。ここでは、編集モードであれば、updateUserHandlerの挙動を、そうでなければ、createUserHandlerを呼ぶようにしています。

const onSubmit: SubmitHandler<PostUser> = async (user: PostUser) => {
  if (id && setIsEdit) {
    await updateUserHandler.mutateAsync({ id, ...user });
    setIsEdit(false);
  } else {
    createUserHandler.mutate(user);
  }
};
コード全体を確認する。
src/components/UserForm.tsx
import React, { type Dispatch, type SetStateAction } from "react";
import { type SubmitHandler, useForm } from "react-hook-form";

import { zodResolver } from "@hookform/resolvers/zod";
import { useHandlerUser } from "@/hooks/useHandlerUser";
import { PostUserSchema, type PostUser } from "@/utils/validation";

export const UserForm = ({
  id,
  user = { name: "", email: "", image: "" },
  isEdit,
  setIsEdit,
}: {
  id?: string;
  user?: PostUser;
  isEdit?: boolean;
  setIsEdit?: Dispatch<SetStateAction<boolean>>;
}) => {
  const [{ createUserHandler, updateUserHandler, upsertUserHandler }] =
    useHandlerUser();

  // config react-hook-form
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<PostUser>({
    resolver: zodResolver(PostUserSchema),
    defaultValues: user,
  });

  const onSubmit: SubmitHandler<PostUser> = async (user: PostUser) => {
    if (id && setIsEdit) {
      await updateUserHandler.mutateAsync({ id, ...user });
      setIsEdit(false);
    } else {
      createUserHandler.mutate(user);
    }
  };

  const onClickUpsert: SubmitHandler<PostUser> = async (user: PostUser) => {
    if (id && setIsEdit) {
      await upsertUserHandler.mutateAsync(user);
      setIsEdit(false);
    } else {
      upsertUserHandler.mutate(user);
    }
  };

  const isTransaction =
    createUserHandler.isLoading ||
    upsertUserHandler.isLoading ||
    updateUserHandler.isLoading;

  const members = ["dragon", "cat", "gorilla", "lion", "shark"];

  return (
    <>
      <div
        className={`md:p-8  sm:p-6  h-96 w-96 max-w-sm rounded-lg border border-gray-200 bg-white p-4 shadow dark:border-gray-700 dark:bg-gray-800 ${
          isTransaction ? "pointer-events-none opacity-50" : ""
        }`}
      >
        <form
          className="space-y-6"
          onSubmit={(event) => void handleSubmit(onSubmit)(event)}
        >
          <div>
            <label className="mb-2 block text-sm font-medium text-gray-900 dark:text-white">
              Your name
              <span className="ms-3 text-red-600">
                {errors.name?.message && errors.name?.message}
              </span>
            </label>
            <input
              {...register("name")}
              className="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-500 dark:bg-gray-600 dark:text-white dark:placeholder-gray-400"
            />
          </div>
          <div>
            <label className="mb-2 block text-sm font-medium text-gray-900 dark:text-white">
              Your email
              <span className="ms-3 text-red-600">
                {errors.email?.message && errors.email?.message}
              </span>
            </label>
            <input
              {...register("email")}
              className={`block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-500 dark:bg-gray-600 dark:text-white dark:placeholder-gray-400 ${
                isEdit ? "pointer-events-none opacity-50" : ""
              }`}
            />
          </div>
          <div>
            <label className="mb-2 block text-sm font-medium text-gray-900 dark:text-white">
              Your image
              <span className="ms-3 text-red-600">
                {errors.image?.message && errors.image?.message}
              </span>
            </label>
            <select
              {...register("image")}
              className="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-500 dark:bg-gray-600 dark:text-white dark:placeholder-gray-400"
            >
              {members.map((member, i) => {
                return (
                  <option key={i} value={member + ".jpg"}>
                    {member}
                  </option>
                );
              })}
            </select>
          </div>

          <div className="mt-4 flex space-x-3 md:mt-6">
            <button
              type="submit"
              className="w-full rounded-lg bg-blue-700 px-5 py-2.5 text-center text-sm font-medium text-white hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
              disabled={
                createUserHandler.isLoading || upsertUserHandler.isLoading
              }
            >
              {id ? "update" : "create"}
            </button>
            {setIsEdit ? (
              <button
                onClick={() => setIsEdit(false)}
                className="w-full rounded-lg bg-blue-700 px-5 py-2.5 text-center text-sm font-medium text-white hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
                disabled={
                  createUserHandler.isLoading || upsertUserHandler.isLoading
                }
              >
                cancel
              </button>
            ) : (
              <button
                onClick={(event) => void handleSubmit(onClickUpsert)(event)}
                className="w-full rounded-lg bg-blue-700 px-5 py-2.5 text-center text-sm font-medium text-white hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
                disabled={
                  createUserHandler.isLoading || upsertUserHandler.isLoading
                }
              >
                upsert
              </button>
            )}
          </div>
        </form>
      </div>
    </>
  );
};

最後に、データベースの情報に基づき表示するユーザーカードを作っておきましょう。

コード全体を確認する。
src/components/UserCard.tsx
import React, { useState, type FC } from "react";
import Image from "next/image";
import { useHandlerUser } from "@/hooks/useHandlerUser";
import { UserForm } from "./UserForm";
import { type User } from "@prisma/client";

export const UserCard: FC<User> = ({ id, name, email, image }) => {
  const [{ deleteUserHandler }] = useHandlerUser();

  const handleDelete = () => {
    deleteUserHandler.mutate({ id });
  };

  // edit
  const [isEdit, setIsEdit] = useState<boolean>(false);
  const user = {
    name: name ?? "",
    email: email ?? "",
    image: image ?? "",
  };

  const isTransaction = deleteUserHandler.isLoading;

  return (
    <>
      {isEdit ? (
        <UserForm id={id} user={user} isEdit={isEdit} setIsEdit={setIsEdit} />
      ) : (
        <div
          className={`h-96 w-96 max-w-sm rounded-lg border border-gray-200 bg-white shadow dark:border-gray-700 dark:bg-gray-800  ${
            isTransaction ? "pointer-events-none opacity-50" : ""
          }`}
        >
          <div className="flex flex-col items-center pb-10 pt-10">
            <Image
              className="mb-3 h-48 w-48 rounded-full shadow-lg"
              width={100}
              height={100}
              loading="lazy"
              src={"/image/" + image ?? "dragon.png"}
              alt={"profile image"}
            />
            <h5 className="mb-1 text-xl font-medium text-gray-900 dark:text-white">
              {name}
            </h5>
            <span className="text-sm text-gray-500 dark:text-gray-400">
              {email}
            </span>
            <div className="mt-4 flex space-x-3 md:mt-6">
              <button
                onClick={() => setIsEdit(true)}
                className="inline-flex items-center rounded-lg bg-blue-700 px-4 py-2 text-center text-sm font-medium text-white hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
              >
                Edit
              </button>
              <button
                onClick={handleDelete}
                className="inline-flex items-center rounded-lg border border-gray-300 bg-white px-4 py-2 text-center text-sm font-medium text-gray-900 hover:bg-gray-100 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:hover:border-gray-700 dark:hover:bg-gray-700 dark:focus:ring-gray-700"
                disabled={deleteUserHandler.isLoading}
              >
                Delete
              </button>
            </div>
          </div>
        </div>
      )}
    </>
  );
};

準備ができたので、index.tsxに戻って、下記のコードを好きなところに埋め込みます。私は、h1の直下に埋め込みました。

src/pages/index.tsx
...
<UserForm />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:gap-8">
  {users?.map((user: User, i) => {
    return <UserCard {...user} key={i} />;
  })}
</div>

コードはこれで完成ですので、一度テストしてみましょう。

私は、こんな感じで実装しています。(Vercel Storage がいっぱいになったら正常に動かないかも)

https://sample-t3-stack-git-dev-eggdragons.vercel.app/

ここで使わせていただいている画像は、Heroic Animalsというプロジェクトのキャラクターです。

ぜひ一度、下記からチェックしてみてくださいね〜。

https://cnpc.my.canva.site/heroicanimals-en


tRPC 編 は以上となります。お疲れ様でした。

もし記事があなたのお役に立ったなら、ぜひ「いいね!」ボタンをクリックしてくださいね。

Discussion