[ハンズオン] 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の更新が確認できたら今回のアプリ作成は完了!お疲れ様でした。