[ハンズオン] Next.js x DrizzleORM x Docker Todoアプリ ~②アプリ作成編~

Next.js

公開日時:2024/06/02

前回記事 [ハンズオン] Next.js x DrizzleORM x Docker Todoアプリ ~①環境構築編~

今回は前回作成した環境で実際にCRUDの操作を行える簡易的なTODOアプリを作成していきます。

1. DBの設定を作成


  • src/db/drizzle.tsを作成する。ここでexportしているdbがschemaを持つことにより、型安全にdb操作を行えるようになる。
drizzle.ts
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';

const queryClient = postgres(process.env.DATABASE_URL || '');
export const db = drizzle(queryClient, { schema });

2. Read (TODO一覧を表示する)


  • まずは studioを起動して add record ボタンを押してアイテムを追加しておく。
  • src/actions/todoActions.tsを作成。今後作成するDB操作に関する関数は全てここに作成していく。
  • getTodosを作成する。
    cache化は必須ではないが、next.jsのfetch関数を使わない場合にfetch関数のようにcacheを使いたい場合に使う。cache化することにより同じ関数を複数回呼び出しても実行は1回で済むようになる。
    https://nextjs.org/docs/app/building-your-application/caching#react-cache-function
todoActions.ts
'use server'; // serverSideの処理であることを明記する

import { db } from '@/db/drizzle';
import { todo } from '@/db/schema';
import { asc } from 'drizzle-orm';
import { cache } from 'react';

export const getTodos = cache(async () => {
  const res = await db.select().from(todo).orderBy(asc(todo.id));
  return res;
});
  • 表示を確認したいので app/page.tsx に表示してみる。デフォルトのmainの中は全て削除して良い。
    簡易的に確認するだけなのでtitleとstatusが表示されるか確認してみる。
    うまくいっていればstudioで作成したアイテムが表示される。
page.tsx
import { getTodos } from '@/actions/todoActions';

// asyncにする必要あり
export default async function Home() {
  const todos = await getTodos();
  return (
    <main className='flex min-h-screen flex-col items-center p-24'>
      {todos.map((todo) => (
        <div
          key={todo.id}
          className='flex items-center justify-between w-full p-4 my-2 bg-white rounded shadow'
        >
          <h2 className='text-xl'>{todo.title}</h2>
          <p>{todo.status}</p>
        </div>
      ))}
    </main>
  );
}
  • 表示が確認できたらtodosをmapで展開している部分をコンポーネント化したいのでtodo-itemsを作成する。
    approuterではapp配下で必要なファイル名が決まっており、それ以外はbuild時は使われない(page.tsxなど)。
    その特性を活かしてapp配下に app/todo-item.tsx を作成する。
  • todo.$inferSelectを使うことでselectクエリを実行したときに得られる型をスキーマに基づいて推論してくれる。
todo-item.tsx
import { todo } from '@/db/schema';

export const TodoItem = ({
  id,
  title,
  status,
  createdAt,
  updatedAt,
}: typeof todo.$inferSelect) => {
  return (
    <div
      className='flex items-center justify-between w-full p-4 my-2 bg-white rounded shadow'
    >
      <h2 className='text-xl'>{title}</h2>
      <p>{status}</p>
      <p>{createdAt.toISOString()}</p>
      <p>{updatedAt.toISOString()}</p>
    </div>
  );
};
  • このコンポーネントを先ほどのmapの中で使っていく。
page.tsx
import { getTodos } from '@/actions/todoActions';
import { TodoItem } from './todo-item';

export default async function Home() {
  const todos = await getTodos();
  return (
    <main className='flex min-h-screen flex-col items-center p-24'>
      {todos.map((todo) => (
        <TodoItem key={todo.id} {...todo} />
      ))}
    </main>
  );
}
  • ここでTODOリストをTableで表示したいのでshadcn/uiを使ってTableを作成していく。
  • bunx shadcn-ui@latest add table button を実行する。
  • page.tsxとtodo-item.tsxをTableに対応するように編集する。
    また、この後追加するCreate,Update,DeleteのUIのみを同時に作成する。
  • ここまででこの章は終了。
page.tsx
import { getTodos } from '@/actions/todoActions';
import { TodoItem } from './todo-item';
import {
  Table,
  TableBody,
  TableCaption,
  TableHead,
  TableHeader,
  TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';

export default async function Home() {
  const todos = await getTodos();
  return (
    <main>
      <h1 className='text-center text-3xl'>drizzle todo</h1>
      <Table>
        <TableCaption>Todo List</TableCaption>
        <TableHeader>
          <TableRow className='hover:bg-transparent'>
            <TableHead>タイトル</TableHead>
            <TableHead>ステータス</TableHead>
            <TableHead>作成日</TableHead>
            <TableHead>更新日</TableHead>
            <TableHead>
              <Button>追加</Button>
            </TableHead>
          </TableRow>
        </TableHeader>
        <TableBody>
          {todos.map((todo) => (
            <TodoItem key={todo.id} {...todo} />
          ))}
        </TableBody>
      </Table>
    </main>
  );
}
todo-item.tsx
import { Button } from '@/components/ui/button';
import { TableRow, TableCell } from '@/components/ui/table';
import { todo } from '@/db/schema';
import { format } from '@formkit/tempo';
import { Pen, Trash } from 'lucide-react';

export const TodoItem = ({
  id,
  title,
  status,
  createdAt,
  updatedAt,
}: typeof todo.$inferSelect) => {
  return (
    <TableRow>
      <TableCell className='min-w-32 max-w-32'>{title}</TableCell>
      <TableCell className='min-w-32 max-w-32'>{status}</TableCell>
      <TableCell className='min-w-32 max-w-32'>
        {format(createdAt, 'YY/MM/DD HH:mm:ss')}
      </TableCell>
      <TableCell className='min-w-32 max-w-32'>
        {format(updatedAt, 'YY/MM/DD HH:mm:ss')}
      </TableCell>
      <TableCell className='flex space-x-2 min-w-32 max-w-32'>
        <Button className='gap-1' variant='outline'>
          <Pen />
          編集
        </Button>
        {
          <Button className='gap-1' variant='destructive'>
            <Trash />
            削除
          </Button>
        }
      </TableCell>
    </TableRow>
  );
};

3. Create (TODOを作成する)


  • 前章で作成した追加ボタンをclickするとTODOが新規作成される機能を作成する。
  • なお、今回は簡易的な実装にするために新規作成時はデフォルトのtitleとstatusが入力される機能のみとする。
  • todoActions.tsにcreateTodoを追加する。
    • formDataを受け取るようになっているが、今回は空のデータが渡ってくるのでtitleは新規作成になる。
    • try catch構文でdb.insert()が成功した場合に revalidatePath()を実行してサーバサイドで取得したデータの再検証を行うことで最新のデータに更新できる。revallidatePath()を実行しない場合、ページ更新などを行わない限りデータの更新は行われない。
    • 編集の機能は最後に実装する。
  • また、schema.tsにinsertTodoSchemaを定義する。このschemaはinsertに必要なschemaをdrizzle-zodがtodoのschemaを元に作成してくれる。
schema.ts
import { createInsertSchema } from 'drizzle-zod';

export const insertTodoSchema = createInsertSchema(todo);
todoActions.ts
import { insertTodoSchema, todo } from '@/db/schema';
import { revalidatePath } from 'next/cache';

export const createTodo = cache(async (formData: FormData) => {
  const newTodo = insertTodoSchema.parse({
    title: formData.get('title') || '新規作成',
  });
  try {
    await db.insert(todo).values(newTodo);
    revalidatePath('/');
  } catch (error) {
    console.error(error);
  }
});
  • 続いて app/create-todo.tsx を作成する。
  • 'use client'にしてクライアントコンポーネントとする。createTodoはformのactionにすることでserver actionとして実行できるが、実行中の非同期の状態を管理するために useFormStatus()を使用している。こうすることにより、pending(アクション実行中)の状態を管理でき、pending中はButtonをdisabledにできるのでボタンが連打されることを防ぐことができる。
    • formの部分はサーバコンポーネントとした方がパフォーマンスとしては良いと思われるが、今回の構成はパフォーマンスを重視していないのでこの構成にする。
create-todo.tsx
'use client';

import { createTodo } from '@/actions/todoActions';
import { Button } from '@/components/ui/button';
import { LoaderIcon, PlusCircleIcon } from 'lucide-react';
import { useFormStatus } from 'react-dom';

export const CreateTodo = () => {
  return (
    <form action={createTodo}>
      <SubmitButton />
    </form>
  );
};

const SubmitButton = () => {
  const { pending } = useFormStatus();
  return (
    <Button type='submit' disabled={pending} className='gap-x-1'>
      {pending ? (
        <LoaderIcon className='animate-spin' />
      ) : (
        <>
          <PlusCircleIcon />
          追加
        </>
      )}
    </Button>
  );
};

作成したコンポーネントをpage.tsxの仮の追加ボタンと置き換える。

page.tsx
import { getTodos } from '@/actions/todoActions';
import { TodoItem } from './todo-item';
import {
  Table,
  TableBody,
  TableCaption,
  TableHead,
  TableHeader,
  TableRow,
} from '@/components/ui/table';
import { CreateTodo } from './create-todo';

export default async function Home() {
  const todos = await getTodos();
  return (
    <main>
      <h1 className='text-center text-3xl'>drizzle todo</h1>
      <Table>
        <TableCaption>Todo List</TableCaption>
        <TableHeader>
          <TableRow className='hover:bg-transparent'>
            <TableHead>タイトル</TableHead>
            <TableHead>ステータス</TableHead>
            <TableHead>作成日</TableHead>
            <TableHead>更新日</TableHead>
            <TableHead>
              <CreateTodo />
            </TableHead>
          </TableRow>
        </TableHeader>
        <TableBody>
          {todos.map((todo) => (
            <TodoItem key={todo.id} {...todo} />
          ))}
        </TableBody>
      </Table>
    </main>
  );
}

追加ボタンをclickするとTODOが新規作成されることが確認できる。
これでこの章も完了。

4. Delete(TODOを削除する)


  • 2章で作成した削除ボタンの機能を作成していく。
  • Createと同様の手順になるが、今回は削除するTODOのidが必要になる。
  • まずはidを元にdbから削除するメソッドdeleteTodoを作成する。
    • 受け取ったidと一致するidを検索して見つかった場合削除を実行する。(idをユニークなものにしているので、idで検索している。)
todoActions.ts
import { asc, eq } from 'drizzle-orm';

export const deleteTodo = cache(async (id: number) => {
  try {
    await db.delete(todo).where(eq(todo.id, id));
    revalidatePath('/');
  } catch (error) {
    console.error(error);
  }
});
  • また、delete-todo.tsxもcreate-todo.tsx同様に作成していく。
  • bindメソッドを利用している。詳細は割愛するが、第一引数のnullはthisの参照,第二引数以降がbindしているメソッド(ここではdeleteTodo)の引数になる。つまり、idがdeleteTodoメソッドの第一引数に渡される形になる。
delete-todo.tsx
'use client';

import { deleteTodo } from '@/actions/todoActions';
import { Button } from '@/components/ui/button';
import { LoaderIcon, TrashIcon } from 'lucide-react';
import { useFormStatus } from 'react-dom';

export const DeleteTodo = ({ id }: { id: number }) => {
  const deleteAction = deleteTodo.bind(null, id);
  return (
    <form action={deleteAction}>
      <SubmitButton />
    </form>
  );
};

export const SubmitButton = () => {
  const { pending } = useFormStatus();
  return (
    <Button
      type='submit'
      variant='destructive'
      disabled={pending}
      className='gap-x-1'
    >
      {pending ? (
        <LoaderIcon className='animate-spin' />
      ) : (
        <>
          <TrashIcon />
          削除
        </>
      )}
    </Button>
  );
};

このDeleteTodoコンポーネントをtodo-item.tsxの仮で作成した削除ボタンと入れ替える。

todo-item.tsx
import { Button } from '@/components/ui/button';
import { TableRow, TableCell } from '@/components/ui/table';
import { todo } from '@/db/schema';
import { format } from '@formkit/tempo';
import { Pen } from 'lucide-react';
import { DeleteTodo } from './delete-todo';

export const TodoItem = ({
  id,
  title,
  status,
  createdAt,
  updatedAt,
}: typeof todo.$inferSelect) => {
  return (
    <TableRow>
      <TableCell className='min-w-32 max-w-32'>{title}</TableCell>
      <TableCell className='min-w-32 max-w-32'>{status}</TableCell>
      <TableCell className='min-w-32 max-w-32'>
        {format(createdAt, 'YY/MM/DD HH:mm:ss')}
      </TableCell>
      <TableCell className='min-w-32 max-w-32'>
        {format(updatedAt, 'YY/MM/DD HH:mm:ss')}
      </TableCell>
      <TableCell className='flex space-x-2 min-w-32 max-w-32'>
        <Button className='gap-1' variant='outline'>
          <Pen />
          編集
        </Button>
        {<DeleteTodo id={id} />} // この部分
      </TableCell>
    </TableRow>
  );
};

実際に画面で削除ボタンをclickするとTODOが削除されていることが確認できる。

5. Update(TODOを編集する)


いよいよ大詰めの編集機能。さまざまな実装方法が考えられるが、今回は以下のような機能で作成する。

  • 編集ボタンを押したらそのTODOの編集モードがONになる。(state管理)
  • 編集モードがONの間はtitleがInput,statusがSelect,編集ボタンの代わりに更新ボタン,削除ボタンの代わりにリセットボタン
  • 更新ボタンがclickされたら更新処理を実行。編集モードをOFF
  • リセットボタンがclickされたら編集前の状態にリセット。編集モードをOFF

まずはInputとSelectをshadcn/uiからインストールする。

bunx shadcn-ui@latest select input

次にshema.tsにupdateTodoSchemaを定義していく。今回はtitleとstatusを更新の対象とし、updatedAtは自動で更新されるようにする。

schema.ts
export const updateTodoSchema = createInsertSchema(todo).pick({
  title: true,
  status: true,
});

次にtodoActions.tsにupdateTodoを定義する。
idとtodoのオブジェクトを受け取り先ほど作成したupdteTodoSchemaにparseしてそれをdbのupdateメソッドで更新するようにする。

todoActions.ts
export const updateTodo = cache(
  async (id: number, updateTodo: typeof todo.$inferInsert) => {
    const title = updateTodo.title;
    const status = updateTodo.status;
    if (!title || !status) return;
    const newTodo = updateTodoSchema.parse({
      title: title,
      status: status,
    });
    try {
      await db.update(todo).set(newTodo).where(eq(todo.id, id));
      revalidatePath('/');
    } catch (error) {
      console.error(error);
    }
  }
);

最後にtodo-item.tsxを編集できるように変更していく。

  • 'use client'に変更し,isEditing(編集モードかどうか),newTodo(編集されたTODO)をstateにする。
  • handleEdit, handleUpdate, handleReset関数を定義する。
  • isEditingの状態により変更する部分を三項演算子で実装する。
todo-item.tsx
'use client';

import { Button } from '@/components/ui/button';
import { TableRow, TableCell } from '@/components/ui/table';
import { todo, updateTodoSchema } from '@/db/schema';
import { DeleteTodo } from './delete-todo';
import { useState } from 'react';
import { updateTodo } from '@/actions/todoActions';
import { z } from 'zod';
import { Pen, RefreshCcw, Upload } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Input } from '@/components/ui/input';
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select';
import { format } from '@formkit/tempo';

export const TodoItem = ({
  id,
  title,
  status,
  createdAt,
  updatedAt,
}: typeof todo.$inferSelect) => {
  const initialTodo = { title, status };
  const [isEditing, setIsEditing] = useState(false);
  const [newTodo, setNewTodo] = useState<z.infer<typeof updateTodoSchema>>({
    title,
    status,
  });

  const handleEdit = () => {
    setNewTodo({ title, status });
    setIsEditing(true);
  };

  const handleUpdate = async () => {
    if (!newTodo) return;
    await updateTodo(id, newTodo);
    setIsEditing(false);
  };

  const handleReset = () => {
    setNewTodo(initialTodo);
    setIsEditing(false);
  };

  return (
    <TableRow className={cn(isEditing && 'bg-teal-100 hover:bg-teal-100')}>
      <TableCell className='min-w-32 max-w-32'>
        {isEditing ? (
          <Input
            value={newTodo.title}
            onChange={(e) =>
              setNewTodo((prev) => ({ ...prev, title: e.target.value }))
            }
          />
        ) : (
          newTodo.title
        )}
      </TableCell>
      <TableCell className='min-w-32 max-w-32'>
        {isEditing ? (
          <Select
            value={newTodo.status}
            onValueChange={(e) =>
              setNewTodo((prev) =>
                updateTodoSchema.parse({
                  ...prev,
                  status: e,
                })
              )
            }
          >
            <SelectTrigger className='w-32'>
              <SelectValue />
            </SelectTrigger>
            <SelectContent>
              <SelectItem value='notStarted'>notStarted</SelectItem>
              <SelectItem value='imProgress'>imProgress</SelectItem>
              <SelectItem value='completed'>completed</SelectItem>
            </SelectContent>
          </Select>
        ) : (
          newTodo.status
        )}
      </TableCell>
      <TableCell className='min-w-32 max-w-32'>
        {format(createdAt, 'YY/MM/DD HH:mm:ss')}
      </TableCell>
      <TableCell className='min-w-32 max-w-32'>
        {format(updatedAt, 'YY/MM/DD HH:mm:ss')}
      </TableCell>
      <TableCell className='flex space-x-2 min-w-32 max-w-32'>
        {isEditing ? (
          <Button className='gap-1' variant='outline' onClick={handleUpdate}>
            <Upload />
            更新
          </Button>
        ) : (
          <Button className='gap-1' variant='outline' onClick={handleEdit}>
            <Pen />
            編集
          </Button>
        )}
        {isEditing ? (
          <Button className='gap-1' variant='destructive' onClick={handleReset}>
            <RefreshCcw />
            リセット
          </Button>
        ) : (
          <DeleteTodo id={id} />
        )}
      </TableCell>
    </TableRow>
  );
};

最後に動作確認を行い,titleやstatusの更新,updatedAtの更新が確認できたら今回のアプリ作成は完了!お疲れ様でした。