📖

Next.js × Recoil × Prismaで始めるTodo App

2022/02/11に公開
2

概要

本記事では、Next.js+Recoil+Prismaを用いて、簡易的なTodo Appを実装していきます。
主に、Recoilを用いた非同期処理について触れていきたいと思います。
全体のコードは以下を参照ください。
https://github.com/yamakenji24/recoil-todo-list

環境構築

本環境のversion一覧

package.json
"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を構築するのに、参考になるドキュメントを以下に記載させていただきます。
https://nextjs.org/docs/getting-started
https://zenn.dev/a_da_chi/articles/181ea4ccc39580

Recoil導入

まずは、Recoilを導入していきます。
npmもしくはyarnでインストールします。

$ npm install recoil

Recoilで状態管理する部分をRecoilRootで囲みます。

pages/_app.tsx
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の設定とモデルを定義していきます。

prisma/schema.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もビルドしていきます

Dockerfile
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 --from=deps /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 --from=builder /app/public ./public
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json

EXPOSE 3000

CMD ["node_modules/.bin/next", "start"]
docker-compose.yml
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せずに、読み取り専用のカスタムフックを作成し、呼び出して使用していきます。

./src/todo-state.ts
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に対応していないため、以下のどれかの対応を行う必要があります。

  1. Next.jsのexperimentalなconcurrentFeaturesの利用
  2. SSR時に取得しない
  3. RecoilのuseRecoilValueLoadableを使用する

1.を利用するには、Reactを18系に上げて、next.config.jsに設定を追加する必要があります。
詳細は以下のドキュメントに記載されています。
https://nextjs.org/docs/advanced-features/react-18

src/pages/index.tsx
return (
  <div>
    ...
    <Suspense fallback={<div>Loading...</div>}>
      <TodoList ... />
    </Suspense>
  </div>
)

ここのTodoListコンポーネントに、先ほど実装したTodoListの取得を行うカスタムフックを呼び出せば、無事取得ができる!
と思えば、そんなことはなく、useRecoilValueを用いてatomから値を取得してくるところで、処理が中断されてしまいます。おそらく、Promiseがちゃんと返せていないのが原因かなと思われますが、今回はSSRしない方式でやっていきたいと思います。(ご存知の方いればぜひ教えてください)
Suspenseより上の階層のコンポーネントをdynamic importしてSSRしないようにします。

pages/_app.tsx
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など非同期処理が扱いやすくなる

といったメリットが得られるかなと思います。
また、管理する状態の操作も追加しやすくなります。

src/dispatcher.ts
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,
  };
};
src/api.ts
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経由で行っています。

src/pages/api/todo
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の削除と更新も同様に行っていきます。
状態の操作は全てカスタムフックにまとめているので、追記していくだけになります。

src/dispatcher.ts
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は開発段階なので、これからも追っていきたいなと思います。

GitHubで編集を提案

Discussion

AsaAsa

訂正

こんにちは
私もyamakenji24さんの記事を参考にTodoアプリを作っているときに以下のエラーに遭遇しました。

docker-compose up -d
service "front" depends on undefined service recoil-todo-mysql: invalid compose project

以下の部分を変更すると解決しました

version: "3"

services:
  front:
    container_name: recoil-todo-front
    build: './'
    depends_on:
      - mysql  <=ここを変更

もしよろしければ今後のために訂正の方をよろしくお願いします。

yamakenji24yamakenji24

ありがとうございます
指摘していただいたエラーを再現できました。
修正させていただきます!