Remixを学びたい(Step4:DBとの疎通をする)

## はじめに
[前回](https://qiita.com/koji0705/items/ae278fb238a2e0aca205)までの続きです！
前回まではMockで対応してましたが、今回はSQLiteのDBにデータを立て、CRUD処理を実施します！！

## シリーズもの
- [Step 1: 基本的なセットアップとTailwindCSS導入](https://qiita.com/koji0705/items/811104b6e32194ed03cc)
  Remixプロジェクトの作成、TailwindCSSのセットアップ、静的なTODOリストの表示

- [Step 2: バックエンドとの疎通](https://qiita.com/koji0705/items/cfc1ae92e8aefd1b3a24)
  Remixのloader関数を使ったデータフェッチ、型定義の作成、ローディング状態の実装

- [Step 3: アクションの実装](https://qiita.com/koji0705/items/ae278fb238a2e0aca205)
  TODOの追加・更新・削除機能の実装、コンポーネントの分割とリファクタリング

- [Step 4: データベース連携](https://qiita.com/koji0705/items/927d91871bb145230117)
  PrismaとSQLiteを使用したデータの永続化、マイグレーションの実行、CRUD操作の実装

## 成果物
![step4.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/599049/9ec392ed-db6f-4076-bf31-574d60a31582.gif)
→SQLiteでDBにデータを保存し、CRUD処理を実装しています！

https://github.com/kouji0705/remix_sample/pull/4

## 実装
```shell:
# Prismaのインストール
npm install -D prisma
npm install @prisma/client
```

```shell:
# Prismaの初期化（SQLite使用）
npx prisma init --datasource-provider sqlite
```
→このコマンドを打つと、`prisma/schema.prisma`が作成されます

```ts:prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = "file:./dev.db"
}

model Todo {
  id        Int      @id @default(autoincrement())
  title     String
  completed Boolean  @default(false)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

```

```shell:env
# Environment variables declared in this file are automatically made available to Prisma.
# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema

# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
# See the documentation for all the connection string options: https://pris.ly/d/connection-strings

DATABASE_URL="file:./dev.db"
```

```shell:migrate実行
~/develop/remix_sample  (feat/step4_db_crud)$ npx prisma migrate dev --name init
```

## ソースコード

```shell:ディレクトリ構成
~/develop/remix_sample  (feat/step4_db_crud)$ tree app/
app/
├── actions
│   └── todoActions.ts 
├── components
│   └── todo
│       ├── TodoForm.tsx
│       ├── TodoItem.tsx
│       └── TodoList.tsx
├── entry.client.tsx
├── entry.server.tsx
├── lib
│   └── db.server.ts # SQLiteの接続Client
├── models
│   └── todo.ts
├── root.tsx
├── routes
│   └── _index.tsx
├── services
│   └── todoService.ts
└── tailwind.css

8 directories, 12 files
```

```ts:app/routes/_index.tsx
import { json, type ActionFunctionArgs } from "@remix-run/node";
import { useLoaderData, useNavigation } from "@remix-run/react";
import type { MetaFunction } from "@remix-run/node";
import { addTodo, deleteTodo, getTodos, toggleTodo } from "~/services/todoService";
import { TodoForm } from "~/components/todo/TodoForm";
import { TodoList } from "~/components/todo/TodoList";
import type { Todo as PrismaTodo } from "@prisma/client";

export type Todo = Omit<PrismaTodo, "createdAt" | "updatedAt"> & {
	createdAt: string;
	updatedAt: string;
};

export const meta: MetaFunction = () => {
	return [
		{ title: "TODO App" },
		{ name: "description", content: "Simple TODO App with Remix" },
	];
};

// データを取得（サーバーサイド）
export async function loader() {
	const todos = await getTodos();
	return json({ todos });
}

// データを更新（サーバーサイド）
export async function action({ request }: ActionFunctionArgs) {
	const formData = await request.formData();
	const intent = formData.get("intent");

	try {
		switch (intent) {
			case "add": {
				const title = formData.get("title");
				if (typeof title !== "string" || !title) {
					return json({ error: "Title is required" }, { status: 400 });
				}
				await addTodo(title);
				break;
			}
			case "toggle": {
				const id = formData.get("id");
				if (typeof id === "string") {
					await toggleTodo(Number.parseInt(id));
				}
				break;
			}
			case "delete": {
				const id = formData.get("id");
				if (typeof id === "string") {
					await deleteTodo(Number.parseInt(id));
				}
				break;
			}
			default: {
				return json({ error: "Invalid intent" }, { status: 400 });
			}
		}
		return json({ ok: true });
	} catch (error) {
		console.error("Database error:", error);
		return json({ error: "データベースエラーが発生しました" }, { status: 500 });
	}
}

export default function Index() {
	const { todos } = useLoaderData<typeof loader>();
	const navigation = useNavigation();
	const isLoading = navigation.state === "loading";

	return (
		<div className="max-w-2xl mx-auto p-4">
			<h1 className="text-2xl font-bold mb-4">TODO App</h1>

			{isLoading ? (
				<div className="text-center text-gray-500 my-4">Loading...</div>
			) : (
				<>
					<TodoForm />
					<TodoList todos={todos} />
				</>
			)}
		</div>
	);
}

```


```ts:app/services/todoService.ts
import { db } from "~/lib/db.server";

export async function getTodos() {
	return db.todo.findMany({
		orderBy: { createdAt: "desc" },
	});
}

export async function addTodo(title: string) {
	return db.todo.create({
		data: {
			title,
			completed: false,
		},
	});
}

export async function toggleTodo(id: number) {
	const todo = await db.todo.findUnique({ where: { id } });
	if (!todo) return null;

	return db.todo.update({
		where: { id },
		data: { completed: !todo.completed },
	});
}

export async function deleteTodo(id: number) {
	return db.todo.delete({
		where: { id },
	});
}

```

```ts:app/actions/todoActions.ts
import { json } from "@remix-run/node";
import { addTodo, toggleTodo, deleteTodo } from "~/services/todoService";

export async function handleAddTodo(formData: FormData) {
	const title = formData.get("title");
	if (typeof title !== "string" || !title) {
		return json({ error: "Title is required" }, { status: 400 });
	}
	await addTodo(title);
	return json({ ok: true });
}

export async function handleToggleTodo(formData: FormData) {
	const id = formData.get("id");
	if (typeof id === "string") {
		await toggleTodo(Number.parseInt(id));
	}
	return json({ ok: true });
}

export async function handleDeleteTodo(formData: FormData) {
	const id = formData.get("id");
	if (typeof id === "string") {
		await deleteTodo(Number.parseInt(id));
	}
	return json({ ok: true });
}
```

```ts:app/lib/db.server.ts
import { PrismaClient } from "@prisma/client";

let db: PrismaClient;

declare global {
	// eslint-disable-next-line no-var
	var __db: PrismaClient | undefined;
}

if (process.env.NODE_ENV === "production") {
	db = new PrismaClient();
} else {
	if (!global.__db) {
		global.__db = new PrismaClient();
	}
	db = global.__db;
}

export { db };

```

```ts:app/models/todo.ts
import type { Todo as PrismaTodo } from "@prisma/client";

// Prismaの型定義を再利用
export type Todo = PrismaTodo;
```

## さいごに
いかがでしたか？Remixがなんとなく理解できてきましたかね？？
まだまだ甘噛み程度なので、これからプロダクトを作ってもっと深く理解していこうと思います！！