Next.js + GrapqhQLでTodoアプリを構築する(後編)

# はじめに

最近GraphQLについて学びまして、
Next.js + GraphQL + GraphQL CodeGenerator + Prismaの構成でTodoアプリを構築したので
軽〜く解説をしつつ、その記録をここに残します。

![スクリーンショット 2023-04-15 20.58.42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2985550/d9c686d4-ca7f-fec9-a91c-1b050cfaafff.png)

# 環境

- Macbook Air
- node
    - v18.13.0
- pnpm
    - 7.27.0

# 目次

- [前編](https://qiita.com/maaaashi/items/fe52db19759ea8a0be31)
    1. Next Create App
    1. GraphQLサーバー構築
    1. Subscription
    1. DB・Prisma
- [中編](https://qiita.com/maaaashi/items/722d75cf8bd25efded88)
    1. GraphQL Schema
    1. GraphQL Context
    1. GraphQL Code Generator
    1. GraphQL Resolver
    1. GraphQLサーバー修正
- [後編](https://qiita.com/maaaashi/items/5c63398a34ac3ea8ddad) 👈 今ここ
    1. フロント側準備
    1. フロント側実装

# 後編

後編はフロント側の実装です。

## 1. フロント側準備

TailwindCSSのフレームワークである`daisyUI`を使っていきます。

https://daisyui.com/

また、アイコンには`react-icons`を使います。

https://react-icons.github.io/react-icons

### 1-1. daisyUI react-icons

- install
    
    ```bash
    $ pnpm add daisyui react-icons
    ```

- tailwind.config.js

    ```js: tailwind.config.js
    module.exports = {
      //...
      plugins: [require("daisyui")],
    }
    ```

- デフォルトのCSS, HTMLの削除

    ```css: global.css
    @tailwind base;
    @tailwind components;
    @tailwind utilities;
    ```

### 1-2. apollo-clientの設定

- install

    ```bash
    pnpm add @apollo/client
    ```

- edit .env

    ```.env
    NEXT_PUBLIC_GRAPHQL_ENDPOINT="http://localhost:3000/api/graphql"
    ```

- edit _app.tsx

    ```tsx: src/pages/_app.tsx
    import "@/styles/globals.css";
    
    import { ApolloClient, ApolloProvider, InMemoryCache } from "@apollo/client";
    import type { AppProps } from "next/app";
    
    export const client = new ApolloClient({
      uri: process.env.NEXT_PUBLIC_GRAPHQL_ENDPOINT,
      cache: new InMemoryCache(),
    });
    
    export default function App({ Component, pageProps }: AppProps) {
      return (
        <ApolloProvider client={client}>
          <Component {...pageProps} />
        </ApolloProvider>
      );
    }
    ```

## 2. フロント側実装

### 2-1. 見た目

まずデータを入れずに見た目を調整します。

![スクリーンショット 2023-04-15 11.01.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2985550/db395e5a-df7e-a45f-15c9-ab269f1b5687.png)


```tsx: src/pages/index.tsx
import { AiOutlineCheckCircle, AiFillCheckCircle } from "react-icons/ai";
import { BsFillTrash2Fill } from "react-icons/bs";

export default function Home() {
  return (
    <div className="min-w-screen min-h-screen" data-theme="winter">
      <form className="flex p-10">
        <input
          type="text"
          placeholder="TODOを入力"
          className="input input-bordered w-full"
        />
        <button className="btn btn-primary">送信</button>
      </form>
      <div
        className="flex flex-col items-center gap-3 overflow-y-auto"
        style={{
          maxHeight: "calc(100vh - 150px)",
        }}
      >
        <div className="p-3 bg-base-300 rounded-md w-2/3 flex items-center gap-3">
          <button>
            {true ? (
              <AiFillCheckCircle
                size={40}
                className="text-primary hover:text-primary-focus"
              />
            ) : (
              <AiOutlineCheckCircle
                size={40}
                className="text-primary hover:text-primary-focus"
              />
            )}
          </button>
          <div className="text-lg font-bold flex-1">hogehoge</div>
          <button>
            <BsFillTrash2Fill
              size={40}
              className="text-error hover:text-error-content"
            />
          </button>
        </div>
      </div>
    </div>
  );
}
```

### 2-2. データ利用

次に[中編](https://qiita.com/maaaashi/items/722d75cf8bd25efded88)で作成した`src/generated/request.ts`を使って、データを入れていきます。

### ◆ listTodo: Todo一覧取得

```diff_tsx: index.tsx
+ import type { ListTodosQuery } from "@/generated/request";
+ import {
+   useListTodosQuery,
+ } from "@/generated/request";
+ import { useEffect, useState } from "react";
import { AiOutlineCheckCircle, AiFillCheckCircle } from "react-icons/ai";
import { BsFillTrash2Fill } from "react-icons/bs";

export default function Home() {
+ const [todos, setTodos] = useState<ListTodosQuery["listTodos"]>([]);
+ const { loading, error, data, refetch } = useListTodosQuery();

+ useEffect(() => {
+   setTodos(data?.listTodos ?? []);
+ }, [data?.listTodos]);

+  if (loading) return <div>loading...</div>;
+  if (error) return <div>error...</div>;
+  if (!data?.listTodos) return <div>data error...</div>;

  return (
    <div className="min-w-screen min-h-screen" data-theme="winter">
      <form className="flex p-10">
        <input
          type="text"
          placeholder="TODOを入力"
          className="input input-bordered w-full"
        />
        <button className="btn btn-primary">送信</button>
      </form>
      <div
        className="flex flex-col items-center gap-3 overflow-y-auto"
        style={{
          maxHeight: "calc(100vh - 150px)",
        }}
      >
+        {todos.map((todo) => (
+          <div
+            className="p-3 bg-base-300 rounded-md w-2/3 flex items-center gap-3"
+            key={todo.id}
+          >
+            <button>
+              {todo.done ? (
+                <AiFillCheckCircle
+                  size={40}
+                  className="text-primary hover:text-primary-focus"
+                />
+              ) : (
+                <AiOutlineCheckCircle
+                  size={40}
+                  className="text-primary hover:text-primary-focus"
+                />
+              )}
+            </button>
+            <div className="text-lg font-bold flex-1">{todo.content}</div>
+            <button>
+              <BsFillTrash2Fill
+                size={40}
+                className="text-error hover:text-error-content"
+              />
+            </button>
+          </div>
+        ))}
-        <div className="p-3 bg-base-300 rounded-md w-2/3 flex items-center gap-3">
-          <button>
-            {true ? (
-              <AiFillCheckCircle
-                size={40}
-                className="text-primary hover:text-primary-focus"
-              />
-            ) : (
-              <AiOutlineCheckCircle
-                size={40}
-                className="text-primary hover:text-primary-focus"
-              />
-            )}
-          </button>
-          <div className="text-lg font-bold flex-1">hogehoge</div>
-          <button>
-            <BsFillTrash2Fill
-              size={40}
-              className="text-error hover:text-error-content"
-            />
-          </button>
-        </div>
      </div>
    </div>
  );
}
```

:::note info
#### Query hooks

```typescript: example
const { loading, error, data, refetch } = useListTodosQuery();
```

```typescript
loading: Boolean => "データ取得中かどうか"
error: Object =>  "エラーが発生した場合、オブジェクトが格納されます"
data: Object => "取得したデータ"
refetch: Function "再取得メソッド(後で登場します)"
```
:::

### ◆ addTodo: Todoを追加

```diff_tsx: index.tsx
import type { ListTodosQuery } from "@/generated/request";
import {
  useListTodosQuery,
+  useAddTodoMutation
} from "@/generated/request";
+ import { FormEvent, useEffect, useState } from "react";
import { AiOutlineCheckCircle, AiFillCheckCircle } from "react-icons/ai";
import { BsFillTrash2Fill } from "react-icons/bs";

export default function Home() {
+  const [todoContent, setTodoContent] = useState("");
  const [todos, setTodos] = useState<ListTodosQuery["listTodos"]>([]);
  const { loading, error, data, refetch } = useListTodosQuery();
+  const [addTodoMutation] = useAddTodoMutation();

  useEffect(() => {
    setTodos(data?.listTodos ?? []);
  }, [data?.listTodos]);

  if (loading) return <div>loading...</div>;
  if (error) return <div>error...</div>;
  if (!data?.listTodos) return <div>data error...</div>;

+  const submitHandler = async (e: FormEvent) => {
+    e.preventDefault();
+
+    await addTodoMutation({
+      variables: {
+        content: todoContent,
+      },
+    });
+
+    setTodoContent("");
+
+    refetch();
+  };

  return (
    <div className="min-w-screen min-h-screen" data-theme="winter">
+      <form className="flex p-10" onSubmit={submitHandler}>
-      <form className="flex p-10">
        <input
          type="text"
          placeholder="TODOを入力"
          className="input input-bordered w-full"
+          value={todoContent}
+          onChange={(e) => {
+            setTodoContent(e.target.value);
+          }}
        />
        <button className="btn btn-primary">送信</button>
      </form>
      <div
        className="flex flex-col items-center gap-3 overflow-y-auto"
        style={{
          maxHeight: "calc(100vh - 150px)",
        }}
      >
        {todos.map((todo) => (
          <div
            className="p-3 bg-base-300 rounded-md w-2/3 flex items-center gap-3"
            key={todo.id}
          >
            <button>
              {todo.done ? (
                <AiFillCheckCircle
                  size={40}
                  className="text-primary hover:text-primary-focus"
                />
              ) : (
                <AiOutlineCheckCircle
                  size={40}
                  className="text-primary hover:text-primary-focus"
                />
              )}
            </button>
            <div className="text-lg font-bold flex-1">{todo.content}</div>
            <button>
              <BsFillTrash2Fill
                size={40}
                className="text-error hover:text-error-content"
              />
            </button>
          </div>
        ))}
      </div>
    </div>
  );
}
```

- 処理の流れ

    ```
    FormSubmit → AddTodo → refetch
    ```
    とすることで、Todoを追加後に最新状態を取得し、反映させています。

:::note info
#### Mutation hooks

```typescript: example
const [addTodoMutation] = useAddTodoMutation();
```

今回は使いませんでしたが、Mutationのhooksの返り値は`[MutationFunction, MutationResult]`の配列となります。

```typescript
[0]: MutationFunction => "Mutationの実行関数"
[1]: MutationResult => {
  data: TData => "Mutationから返されたデータ"
  loading: Boolean => "取得中かどうか"
  error: Object => "エラーが発生した場合、オブジェクトが格納されます"
  called: Boolean => "trueの場合、呼び出されています"
  client: ApolloClient => "Mutationを実行したApollo Client情報"
  reset: Function => "Mutationの状態を初期の呼び出される前に戻す処理"
}]
```
:::

### ◆ updateTodo: Todoを更新

mutationの使い方はaddTodoと変わりません

```diff_tsx: index.tsx
import type { ListTodosQuery } from "@/generated/request";
import {
  useListTodosQuery,
  useAddTodoMutation,
+  useUpdateTodoMutation,
} from "@/generated/request";
import { FormEvent, useEffect, useState } from "react";
import { AiOutlineCheckCircle, AiFillCheckCircle } from "react-icons/ai";
import { BsFillTrash2Fill } from "react-icons/bs";

export default function Home() {
  const [todoContent, setTodoContent] = useState("");
  const [todos, setTodos] = useState<ListTodosQuery["listTodos"]>([]);
  const { loading, error, data, refetch } = useListTodosQuery();
  const [addTodoMutation] = useAddTodoMutation();
+  const [updateTodoMutation] = useUpdateTodoMutation();

  useEffect(() => {
    setTodos(data?.listTodos ?? []);
  }, [data?.listTodos]);

  if (loading) return <div>loading...</div>;
  if (error) return <div>error...</div>;
  if (!data?.listTodos) return <div>data error...</div>;

  const submitHandler = async (e: FormEvent) => {
    e.preventDefault();

    await addTodoMutation({
      variables: {
        content: todoContent,
      },
    });

    setTodoContent("");

    refetch();
  };

+  const updateHandler = async (id: string, current: boolean) => {
+    await updateTodoMutation({
+      variables: {
+        id,
+        done: !current,
+      },
+    });
+
+    refetch();
+  };

  return (
    <div className="min-w-screen min-h-screen" data-theme="winter">
      <form className="flex p-10" onSubmit={submitHandler}>
        <input
          type="text"
          placeholder="TODOを入力"
          className="input input-bordered w-full"
          value={todoContent}
          onChange={(e) => {
            setTodoContent(e.target.value);
          }}
        />
        <button className="btn btn-primary">送信</button>
      </form>
      <div
        className="flex flex-col items-center gap-3 overflow-y-auto"
        style={{
          maxHeight: "calc(100vh - 150px)",
        }}
      >
        {todos.map((todo) => (
          <div
            className="p-3 bg-base-300 rounded-md w-2/3 flex items-center gap-3"
            key={todo.id}
          >
            <button
+              onClick={() => {
+                updateHandler(todo.id, todo.done);
+              }}
            >
              {todo.done ? (
                <AiFillCheckCircle
                  size={40}
                  className="text-primary hover:text-primary-focus"
                />
              ) : (
                <AiOutlineCheckCircle
                  size={40}
                  className="text-primary hover:text-primary-focus"
                />
              )}
            </button>
            <div className="text-lg font-bold flex-1">{todo.content}</div>
            <button>
              <BsFillTrash2Fill
                size={40}
                className="text-error hover:text-error-content"
              />
            </button>
          </div>
        ))}
      </div>
    </div>
  );
}
```

### ◆ deleteTodo: Todoを削除

mutationの使い方はaddTodoと変わりません

```diff_tsx: index.tsx
import type { ListTodosQuery } from "@/generated/request";
import {
  useListTodosQuery,
  useAddTodoMutation,
  useUpdateTodoMutation,
+  useDeleteTodoMutation,
} from "@/generated/request";
import { FormEvent, useEffect, useState } from "react";
import { AiOutlineCheckCircle, AiFillCheckCircle } from "react-icons/ai";
import { BsFillTrash2Fill } from "react-icons/bs";

export default function Home() {
  const [todoContent, setTodoContent] = useState("");
  const [todos, setTodos] = useState<ListTodosQuery["listTodos"]>([]);
  const { loading, error, data, refetch } = useListTodosQuery();
  const [addTodoMutation] = useAddTodoMutation();
  const [updateTodoMutation] = useUpdateTodoMutation();
+  const [deleteTodoMutation] = useDeleteTodoMutation();

  useEffect(() => {
    setTodos(data?.listTodos ?? []);
  }, [data?.listTodos]);

  if (loading) return <div>loading...</div>;
  if (error) return <div>error...</div>;
  if (!data?.listTodos) return <div>data error...</div>;

  const submitHandler = async (e: FormEvent) => {
    e.preventDefault();

    await addTodoMutation({
      variables: {
        content: todoContent,
      },
    });

    setTodoContent("");

    refetch();
  };

  const updateHandler = async (id: string, current: boolean) => {
    await updateTodoMutation({
      variables: {
        id,
        done: !current,
      },
    });

    refetch();
  };

+  const deleteHandler = async (id: string) => {
+    await deleteTodoMutation({
+      variables: { id },
+    });
+
+    refetch();
+  };

  return (
    <div className="min-w-screen min-h-screen" data-theme="winter">
      <form className="flex p-10" onSubmit={submitHandler}>
        <input
          type="text"
          placeholder="TODOを入力"
          className="input input-bordered w-full"
          value={todoContent}
          onChange={(e) => {
            setTodoContent(e.target.value);
          }}
        />
        <button className="btn btn-primary">送信</button>
      </form>
      <div
        className="flex flex-col items-center gap-3 overflow-y-auto"
        style={{
          maxHeight: "calc(100vh - 150px)",
        }}
      >
        {todos.map((todo) => (
          <div
            className="p-3 bg-base-300 rounded-md w-2/3 flex items-center gap-3"
            key={todo.id}
          >
            <button
              onClick={() => {
                updateHandler(todo.id, todo.done);
              }}
            >
              {todo.done ? (
                <AiFillCheckCircle
                  size={40}
                  className="text-primary hover:text-primary-focus"
                />
              ) : (
                <AiOutlineCheckCircle
                  size={40}
                  className="text-primary hover:text-primary-focus"
                />
              )}
            </button>
            <div className="text-lg font-bold flex-1">{todo.content}</div>
            <button
+              onClick={() => {
+                deleteHandler(todo.id);
+              }}
            >
              <BsFillTrash2Fill
                size={40}
                className="text-error hover:text-error-content"
              />
            </button>
          </div>
        ))}
      </div>
    </div>
  );
}
```

完成です！！

前編・中編・後編と長い記事となりましたが、とてもワクワクするような構成でした。