😄

Next.jsのApp RouterでTodoアプリを作る

2024/10/01に公開

Next.jsのApp RouterでTodoアプリを作る

App Routerを触ってなかったので、Todoアプリを作ることでApp Routerについて学習していこうと思います。useStateでタスクを管理するのではなく、実際にデータベースに書き込むTodoアプリを作っていこうと思います。

技術スタック

  • Next.js(app)
  • TailwindCSS
  • Prisma
  • PostgreSQL
  • docker-compose

プロジェクトの開始

コマンドで一発
質問は適当にYES

npx create-next-app .

今回のディレクトリ構造

src
  ├ app
    ├ feature
      ├ todo
  ├ components // コンポーネント
  ├ db // drizzle関連

ライブラリのインストール

※eslint, prettierは好みで

npx shadcn@latest init
npm install drizzle-orm postgres
npm install -D drizzle-kit

drizzleのセットアップ

Drizzleの接続のメモ記事参照

使用するshadcnコンポーネント

npx shadcn@latest add alert-dialog button card input label

Todoアプリを作る

ダイアログコンポーネントの作成

削除するときの確認ダイアログを表示します。見栄えのために作っただけなので、いらない場合作る必要はありません

import { GeistSans } from "geist/font/sans";
import React, { ReactNode } from "react";

import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
  AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { cn } from "@/utils/cn";

export const DeleteTaskAlert = ({
  children,
  onClick,
}: {
  children: ReactNode;
  onClick: React.ComponentProps<"button">["onClick"];
}) => (
  <AlertDialog>
    <AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
    <AlertDialogContent className={cn(GeistSans.className)}>
      <AlertDialogHeader>
        <AlertDialogTitle>タスクを削除しますか?</AlertDialogTitle>
        <AlertDialogDescription>
          この操作は取り消せません
        </AlertDialogDescription>
      </AlertDialogHeader>
      <AlertDialogFooter>
        <AlertDialogCancel>キャンセル</AlertDialogCancel>
        <AlertDialogAction
          className={cn("bg-red-500 hover:bg-red-600")}
          onClick={onClick}
          asChild
        >
          <Button>削除する</Button>
        </AlertDialogAction>
      </AlertDialogFooter>
    </AlertDialogContent>
  </AlertDialog>
);

TodoForm

タスクを追加するフォームコンポーネントです。FormActionを使っています。これについてもまだ調べる必要あり。クライアント側で関数を作ることでformの値をリセットするにしました

"use client";

import { useRef } from "react";

import { addTask } from "../actions/submit";

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/utils/cn";


export const TodoForm = () => {
  const formRef = useRef<HTMLFormElement | null>(null);

  const submitFunc = async (data: FormData) => {
    await addTask(data);
    if (formRef.current) {
      formRef.current.reset();
    }
  };

  return (
    <form action={submitFunc} className="flex flex-col gap-2" ref={formRef}>
      <Label>タスク名を記述してください</Label>
      <Input
        name="todo"
        className={cn("focus:outline-sky-600 focus-visible:ring-sky-600")}
      />
      <Button className="w-fit bg-sky-500 hover:bg-sky-600">
        タスクを追加
      </Button>
    </form>
  );
};

TodoList

親から渡されたtodosをmapを使ってリスト形式に表示。削除ボタンをクリックするとそのタスクを削除することができる

"use client";

import { deleteTodoFunc } from "../actions/delete";

import { DeleteTaskAlert } from "@/components/notifications/alert-dialog/delete-task";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";

export const TodoList = (props: {
  todos: {
    id: number;
    title: string;
    createdAt: Date;
    completed: boolean;
    updatedAt: Date;
  }[];
}) => {
  const { todos } = props;

  return (
    <div className="flex flex-col gap-4">
      {todos.map((todo) => (
        <Card key={todo.id}>
          <CardHeader>
            <CardTitle>{todo.title}</CardTitle>
            <CardDescription>
              {new Date(todo.createdAt).toLocaleDateString("sv-SE")}
            </CardDescription>
          </CardHeader>
          <CardContent>
            <form>
              <DeleteTaskAlert onClick={() => deleteTodoFunc(todo.id)}>
                <Button variant={"destructive"}>削除</Button>
              </DeleteTaskAlert>
            </form>
          </CardContent>
        </Card>
      ))}
    </div>
  );
};

データベース操作の関数

今回はタスクの追加と削除のみを実装。サーバーサイド側で処理を行う。

"use server";

import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";

import { db } from "@/db/client";
import { todos } from "@/db/schema";

export const addTodo = async (data: FormData) => {
  const todo = data.get("todo") as string;

  try {
    await db
      .insert(todos)
      .values({
        title: todo,
        completed: false,
      })
      .returning();
    revalidatePath("/");
  } catch (e) {
    if (e instanceof Error) {
      throw new Error(e.message);
    }
  }
};

export const deleteTodo = async (id: number) => {
  try {
    await db.delete(todos).where(eq(todos.id, id)).returning();
    revalidatePath("/");
  } catch (e) {
    if (e instanceof Error) {
      throw new Error(e.message);
    }
  }
};

addTask

タスクを追加する

"use server";
import { addTodo } from "./todo";

export const addTask = async (data: FormData) => {
  const todo = data.get("todo") as string;
  if (!todo) return;
  if (!todo.length) return;
  if (!todo.trim()) return;

  await addTodo(data);
};

deleteTask

タスクを削除する

"use server";

import { deleteTodo } from "./todo";

export const deleteTodoFunc = async (id: number) => {
  if (!id) return;

  await deleteTodo(id);
};

ページ

  • 今回はペライチアプリなのでapp/page.tsxのみ使用する。
  • RSCでは直接データベースにアクセスることができるらしい?
import { TodoForm } from "./_feature/todo/components/todo-form";
import { TodoList } from "./_feature/todo/components/todo-list";

import { db } from "@/db/client";
import { todos as todosSchema } from "@/db/schema";


const Top = async () => {
  const todos = await db.select().from(todosSchema).orderBy(todosSchema.id);

  return (
    <>
      <main className="h-screen w-screen overflow-x-hidden overflow-y-scroll">
        <div className="mx-auto flex w-4/5 flex-col gap-8 py-8 lg:w-2/5">
          <h2 className="text-4xl font-bold">Todoアプリ</h2>
          <TodoForm />
          <TodoList todos={todos} />
        </div>
      </main>
    </>
  );
};

export default Top;

完成

Todoアプリ

汚いコードではあるがとりあえず動作するものは作れた。axiosでapiルートに通信せずとも直接データベースを操作できるのはとても面白い

参考記事

最後に

間違っていることがあればコメントに書いていただけると幸いです。
よろしくお願いいたします。

GitHubで編集を提案

Discussion