9
2

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.

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

Last updated at Posted at 2023-04-16

はじめに

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

スクリーンショット 2023-04-15 20.58.42.png

環境

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

目次

  • 前編
    1. Next Create App
    2. GraphQLサーバー構築
    3. Subscription
    4. DB・Prisma
  • 中編
    1. GraphQL Schema
    2. GraphQL Context
    3. GraphQL Code Generator
    4. GraphQL Resolver
    5. GraphQLサーバー修正
  • 後編 👈 今ここ
    1. フロント側準備
    2. フロント側実装

後編

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

1. フロント側準備

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

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

1-1. daisyUI react-icons

  • install

    $ pnpm add daisyui react-icons
    
  • tailwind.config.js

    tailwind.config.js
    module.exports = {
      //...
      plugins: [require("daisyui")],
    }
    
  • デフォルトのCSS, HTMLの削除

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

1-2. apollo-clientの設定

  • install

    pnpm add @apollo/client
    
  • edit .env

    .env
    NEXT_PUBLIC_GRAPHQL_ENDPOINT="http://localhost:3000/api/graphql"
    
  • edit _app.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

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. データ利用

次に中編で作成したsrc/generated/request.tsを使って、データを入れていきます。

◆ listTodo: Todo一覧取得

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>
  );
}

Query hooks

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

◆ addTodo: Todoを追加

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を追加後に最新状態を取得し、反映させています。

Mutation hooks

example
const [addTodoMutation] = useAddTodoMutation();

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

[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と変わりません

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と変わりません

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>
  );
}

完成です!!

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

9
2
0

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
9
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?