はじめに
最近GraphQLについて学びまして、
Next.js + GraphQL + GraphQL CodeGenerator + Prismaの構成でTodoアプリを構築したので
軽〜く解説をしつつ、その記録をここに残します。
環境
- Macbook Air
- node
- v18.13.0
- pnpm
- 7.27.0
目次
-
前編
- Next Create App
- GraphQLサーバー構築
- Subscription
- DB・Prisma
-
中編
- GraphQL Schema
- GraphQL Context
- GraphQL Code Generator
- GraphQL Resolver
- GraphQLサーバー修正
-
後編 👈 今ここ
- フロント側準備
- フロント側実装
後編
後編はフロント側の実装です。
1. フロント側準備
TailwindCSSのフレームワークであるdaisyUI
を使っていきます。
また、アイコンにはreact-icons
を使います。
1-1. daisyUI react-icons
-
install
$ pnpm add daisyui react-icons
-
tailwind.config.js
tailwind.config.jsmodule.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
.envNEXT_PUBLIC_GRAPHQL_ENDPOINT="http://localhost:3000/api/graphql"
-
edit _app.tsx
src/pages/_app.tsximport "@/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. 見た目
まずデータを入れずに見た目を調整します。
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一覧取得
+ 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>
);
}
◆ addTodo: Todoを追加
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
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と変わりません
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と変わりません
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>
);
}
完成です!!
前編・中編・後編と長い記事となりましたが、とてもワクワクするような構成でした。