Next.js × Recoil × Prismaで始めるTodo App
概要
本記事では、Next.js+Recoil+Prismaを用いて、簡易的なTodo Appを実装していきます。
主に、Recoilを用いた非同期処理について触れていきたいと思います。
全体のコードは以下を参照ください。
環境構築
本環境のversion一覧
"next": "^12.0.10",
"react": "^18.0.0-rc.0",
"react-dom": "^18.0.0-rc.0",
"recoil": "^0.6.1"
Next.jsの構築
Next.jsを構築するのに、参考になるドキュメントを以下に記載させていただきます。
Recoil導入
まずは、Recoilを導入していきます。
npmもしくはyarnでインストールします。
$ npm install recoil
Recoilで状態管理する部分をRecoilRootで囲みます。
import { AppProps } from "next/app";
import { RecoilRoot } from "recoil";
export default function MyApp({ Component, pageProps }: AppProps) {
return (
<RecoilRoot>
<Component {...pageProps} />
</RecoilRoot>
);
}
Prisma導入
prismaのインストールとスキーマを生成していきます。
$ npm install @prisma/client
$ npm install -D prisma
$ npx prisma init
schema.prismaにprismaの設定とモデルを定義していきます。
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
model Todo {
id Int @id @default(autoincrement())
text String
completed Boolean @default(false)
}
Dockerでまとめて管理
ローカルの環境を汚したくないので、DockerでmysqlとついでにNext.jsもビルドしていきます
FROM node:16.13.1-alpine3.12 AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:16.13.1-alpine3.12 AS builder
WORKDIR /app
COPY . .
COPY /app/node_modules ./node_modules
RUN npm run build:all && npm install --production --ignore-scripts --prefer-offline
FROM node:16.13.1-alpine3.12 AS runner
WORKDIR /app
COPY /app/public ./public
COPY /app/.next ./.next
COPY /app/node_modules ./node_modules
COPY /app/package.json ./package.json
EXPOSE 3000
CMD ["node_modules/.bin/next", "start"]
version: "3"
services:
front:
container_name: recoil-todo-front
build: './'
depends_on:
- mysql
environment:
- DATABASE_URL=${DATABASE_URL}
ports:
- '3000:3000'
restart: always
mysql:
container_name: recoil-todo-mysql
image: mysql:5.7
environment:
MYSQL_DATABASE: prisma
MYSQL_ROOT_PASSWORD: prisma
volumes:
- 'mysql-data:/var/lib/mysql'
ports:
- '3306:3306'
restart: always
volumes:
mysql-data:
これで、docker compose up
でまとめて起動できると思います。
Todoの状態管理
TodoListの読み取り
TodoListの初期値を、DBから取得した値を利用するために、atomのdefault内でselectorを用いて非同期処理を行っていきます。
atomは状態を保持しているところで、ReduxにおけるStoreみたいな感じだと思います。
これらのatomは直接exportせずに、読み取り専用のカスタムフックを作成し、呼び出して使用していきます。
import { atom, selector, useRecoilValue } from "recoil";
import { fetchTodoListAPI } from "./api";
export interface TodoItem {
id: number;
text: string;
completed: boolean;
}
export const todoListState = atom<TodoItem[]>({
key: "todoListState",
default: selector({
key: "initialTodoListState",
get: async () => await fetchTodoListAPI(),
}),
});
const todoListSelector = selector({
key: "todoListSelector",
get: ({ get }) => get(todoListState),
});
export const todoSelectors = {
useGetTodoList: () => useRecoilValue(todoListSelector),
};
なお、現段階ではNext.jsのSSRでSuspenseに対応していないため、以下のどれかの対応を行う必要があります。
- Next.jsのexperimentalなconcurrentFeaturesの利用
- SSR時に取得しない
- RecoilのuseRecoilValueLoadableを使用する
1.を利用するには、Reactを18系に上げて、next.config.jsに設定を追加する必要があります。
詳細は以下のドキュメントに記載されています。
return (
<div>
...
<Suspense fallback={<div>Loading...</div>}>
<TodoList ... />
</Suspense>
</div>
)
ここのTodoListコンポーネントに、先ほど実装したTodoListの取得を行うカスタムフックを呼び出せば、無事取得ができる!
と思えば、そんなことはなく、useRecoilValueを用いてatomから値を取得してくるところで、処理が中断されてしまいます。おそらく、Promiseがちゃんと返せていないのが原因かなと思われますが、今回はSSRしない方式でやっていきたいと思います。(ご存知の方いればぜひ教えてください)
Suspenseより上の階層のコンポーネントをdynamic importしてSSRしないようにします。
export default function MyApp({ Component, pageProps }: AppProps) {
const NoSSR = dynamic(() => import("src/components/NoSSR"));
return (
<NoSSR>
<RecoilRoot>
<Component {...pageProps} />
</RecoilRoot>
</NoSSR>
);
}
Todoの追加処理
atomに新しいTodoを追加していきたいと思います。
atomの状態を更新するには、useStateのようなsetterを持つuseSetRecoilStateを使うこともできますが、今回はuseRecoilCallbackを利用した、actionのようなカスタムフックを作成していきます。これにより、
- 状態に対する操作をカスタムフックに閉じ込めることができる
- API Callなど非同期処理が扱いやすくなる
といったメリットが得られるかなと思います。
また、管理する状態の操作も追加しやすくなります。
import { useRecoilCallback } from "recoil";
import { todoListState } from "./todo-state";
import { addTodoAPI } from "./api";
export const useCreateDispatcher = () => {
const addTodo = useRecoilCallback(({ set }) => async (text: string) => {
const newTodo = await addTodoAPI(text);
set(todoListState, (oldTodos) => [...oldTodos, newTodo]);
}, []);
return {
addTodo,
};
};
export const addTodoAPI = async (text: string): Promise<TodoItem> => {
return await fetch("http://localhost:3000/api/todo", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ text }),
}).then((res) => res.json());
};
prismaの操作は基本的に、next.jsのapi経由で行っています。
import type { NextApiRequest, NextApiResponse } from "next";
import { prisma } from "src/lib/prisma";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
switch (req.method) {
case "GET":
const todos = await prisma.todo.findMany();
return res.status(200).json(todos);
case "POST":
const todo = await prisma.todo.create({
data: {
text: req.body.text,
},
});
return res.status(200).json(todo);
default:
return res.send("Method not allowed");
}
}
Todoの削除、更新
Todoの削除と更新も同様に行っていきます。
状態の操作は全てカスタムフックにまとめているので、追記していくだけになります。
import { useRecoilCallback } from "recoil";
import { todoListState } from "./todo-state";
import { addTodoAPI, deleteTodoAPI, updateTodoAPI } from "./api";
export const useCreateDispatcher = () => {
const addTodo = useRecoilCallback(
...
);
const deleteTodo = useRecoilCallback(({ set }) => async (id: number) => {
await deleteTodoAPI(id);
set(todoListState, (oldTodos) =>
oldTodos.filter((todo) => todo.id !== id)
);
}, []);
const updateTodo = useRecoilCallback(({ set }) =>
async (id: number, text: string) => {
const updatedTodo = await updateTodoAPI(id, text);
set(todoListState, (oldTodos) =>
oldTodos.map((todo) => (todo.id === id ? updatedTodo : todo))
);
}, []);
return {
addTodo,
deleteTodo,
updateTodo,
};
};
まとめ
本記事では、Next.js+Recoil+Prismaを用いて、簡易的なTodo Appを実装していきました。
Recoilで非同期処理を行うためにSuspenseの振り返りなど、いろいろ調べていて面白いなと思いました。
個人的な感想として、useStateをグローバルに拡張した感じで簡単に扱えるので楽だったなと思う反面、非同期処理の扱いが少しめんどくさいなと感じました。
特に、SSR時に非同期処理を行う際に、Suspenseに対する対応をどうしようと悩みました。
まだまだRecoilは開発段階なので、これからも追っていきたいなと思います。
Discussion
訂正
こんにちは
私もyamakenji24さんの記事を参考にTodoアプリを作っているときに以下のエラーに遭遇しました。
以下の部分を変更すると解決しました
もしよろしければ今後のために訂正の方をよろしくお願いします。
ありがとうございます
指摘していただいたエラーを再現できました。
修正させていただきます!