Chapter 04

UIの実装 (Next.js/tailwind vs ActionView)

ykpythemind
ykpythemind
2022.09.28に更新

Next.js でUIを構築する

前のチャプターで作ったAPIで、UIの実装をしていきます。

Railsチュートリアル にあるような、ユーザーの一覧ページを作ります。

Pages

src/pages/users.tsx ファイルを作成し Pages を実装します

import type { NextPage } from "next";
import React, { useState } from "react";
import { trpc } from "../utils/trpc";

const UserIndexPage: NextPage = () => {
  const getUsers = trpc.user.getUsers.useQuery();
  const [selectedUserId, setSelectedUserId] = useState<string | null>(null);

  if (!getUsers.data) {
    return <div>Loading...</div>;
  }

  return (
    <>
      <main className="container p-4 mx-auto max-w-screen-lg">
        <div className="flex flex-row justify-center">
          <div className="w-1/2">
            {getUsers.data.map((user) => (
              <UserRow
                key={user.id}
                name={user.name ?? "--"}
                onSelectUser={() => setSelectedUserId(user.id)}
		isSelected={selectedUserId === user.id}
              />
            ))}
          </div>

          <div className="w-1/2 ml-2 px-2 border-l-gray-200 border-l-2">
            <div className="font-bold">詳細</div>

            {!selectedUserId && <div>ユーザーが選択されていません</div>}
          </div>
        </div>
      </main>
    </>
  );
};

const UserRow: React.FC<{ name: string; onSelectUser: () => void, isSelected: boolean }> = ({
  name,
  onSelectUser,
  isSelected,
}) => {
  return (
    <div className="flex flex-row py-2 space-x-3">
      <div>
        {isSelected ? "✓" : ""}
        {name}
      </div>
      <div>
        <button type="button" className="text-blue-700" onClick={onSelectUser}>
          選択
        </button>
      </div>
    </div>
  );
};

export default UserIndexPage;

左側にユーザー一覧を出し、右側にそのユーザーの詳細を出すことにしました。ユーザーの詳細ではそのユーザーのMicropostを表示します。

useQuery

const getUsers = trpc.user.getUsers.useQuery();

この行で、前チャプターで定義したprocedureからユーザーを取得しています。useQueryの実装は React Query(TanStack Query) の実装です。useQuery() しているコンポーネントがマウントされたときにgetUsers procedureを呼び出しユーザーを取得しにいきます。

ユーザーのリストが取れたらそれをレンダリングしてあげるだけです。

tailwind

<main className="container p-4 mx-auto max-w-screen-lg">

こちらで当てているcssクラスは tailwindcss のものです。tailwindはユーティリティファーストのcssライブラリで、ここでは説明を割愛します。

ユーザーの詳細を実装する

diff --git a/src/pages/users.tsx b/src/pages/users.tsx
index f9f0cc3..01d560c 100644
--- a/src/pages/users.tsx
+++ b/src/pages/users.tsx
@@ -6,6 +6,11 @@ const UserIndexPage: NextPage = () => {
   const getUsers = trpc.user.getUsers.useQuery();
   const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
 
+  const getUserMicroposts = trpc.micropost.getUserMicroposts.useQuery(
+    { userId: selectedUserId ?? "never" },
+    { enabled: selectedUserId !== null }
+  );
+
   if (!getUsers.data) {
     return <div>Loading...</div>;
   }
@@ -28,6 +33,9 @@ const UserIndexPage: NextPage = () => {
             <div className="font-bold">詳細</div>
 
             {!selectedUserId && <div>ユーザーが選択されていません</div>}
+            {getUserMicroposts?.data?.map((micropost) => (
+              <div key={micropost.id}>- {micropost.content}</div>
+            ))}
           </div>
         </div>
       </main>

Image from Gyazo

ユーザーが選択されている場合にのみ getUserMicropostsが実行されて、ユーザーに紐付いたmicropostsが取得されます。

Micropostを登録する

diff --git a/src/pages/users.tsx b/src/pages/users.tsx
index 1ebd7b3..f335f07 100644
--- a/src/pages/users.tsx
+++ b/src/pages/users.tsx
@@ -6,6 +6,22 @@ const UserIndexPage: NextPage = () => {
   const getUsers = trpc.user.getUsers.useQuery();
   const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
 
+  const [inputContent, setInputContent] = useState("");
+
+  const utils = trpc.useContext();
+  const createMicropost = trpc.micropost.createMicropost.useMutation({
+    onSuccess: async () => {
+      setInputContent("");
+      utils.micropost.getUserMicroposts.invalidate();
+    },
+  });
+
+  const onClickContentSubmit = async () => {
+    if (selectedUserId) {
+      createMicropost.mutate({ userId: selectedUserId, content: inputContent });
+    }
+  };
+
   const getUserMicroposts = trpc.micropost.getUserMicroposts.useQuery(
     { userId: selectedUserId ?? "never" },
     { enabled: selectedUserId !== null }
@@ -37,6 +53,26 @@ const UserIndexPage: NextPage = () => {
             {getUserMicroposts?.data?.map((micropost) => (
               <div key={micropost.id}>- {micropost.content}</div>
             ))}
+
+            {selectedUserId && (
+              <div className="m-2 p-2 border-gray-200 border-2 rounded-sm">
+                <label htmlFor={"content"}>内容</label>
+                <input
+                  type="text"
+                  id={"content"}
+                  value={inputContent}
+                  onChange={(e) => setInputContent(e.target.value)}
+                  className={"border-gray-100 border-2"}
+                />
+                <button
+                  type="button"
+                  onClick={onClickContentSubmit}
+                  className={"p-1 text-blue-700"}
+                >
+                  登録
+                </button>
+              </div>
+            )}
           </div>
         </div>
       </main>

Image from Gyazo

useMutation() フックを使います。これも実装は React Query(TanStack Query) です。

onSuccess: async () => {
  setInputContent("");
  utils.micropost.getUserMicroposts.invalidate();
}

useMutationが成功したときにはテキストフィールドの内容を初期化し、 getUserMicropostsを再取得することで見た目に反映させています。

mutationからエラー情報を取り出すこともできます。

diff --git a/src/pages/users.tsx b/src/pages/users.tsx
index f335f07..5fc5873 100644
--- a/src/pages/users.tsx
+++ b/src/pages/users.tsx
@@ -71,6 +71,11 @@ const UserIndexPage: NextPage = () => {
                 >
                   登録
                 </button>
+                {createMicropost.error && (
+                  <p className="text-red-800">
+                    エラー! {createMicropost.error.message}
+                  </p>
+                )}
               </div>
             )}
           </div>

Image from Gyazo

内容を未入力のままサーバにPOSTしてしまった例です。そのままのエラーの構造が出ていますが、こちらは別途チャプターでエラーハンドリングの詳細を解説します。


以上で、データの取得と更新を実装することができました。
特にコード生成の必要もなく、その場ですぐ補完が効いていく体験がとても楽です。

続きます。