Remixを学びたい(Step3:アクションを実装する)

## はじめに
[前回](https://qiita.com/koji0705/items/cfc1ae92e8aefd1b3a24)までの続きです。
今回はアクションを実装し、TODOの新規追加や更新、削除の実装をしていきます！

## シリーズもの
- [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操作の実装

## 成果物

![step3.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/599049/dbbb09c3-2c79-49ff-b95d-32307285481d.gif)
→今回は新規追加や更新、削除の実装をしています

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


```mermaid
sequenceDiagram
    actor User
    participant Browser
    participant TodoForm
    participant TodoItem
    participant Remix_Client
    participant Action
    participant TodoService
    participant MockData

    Note over Browser,MockData: 1. 新規TODOの追加
    User->>TodoForm: TODOを入力して追加ボタンクリック
    TodoForm->>Remix_Client: Form送信
    Remix_Client->>Action: POST /リクエスト
    Action->>Action: handleAddTodo
    Action->>TodoService: addTodo(title)
    TodoService->>MockData: 配列にTODO追加
    TodoService-->>Action: 完了
    Action-->>Remix_Client: JSON応答
    Remix_Client->>TodoService: getTodos()
    TodoService->>MockData: モックデータ取得
    MockData-->>TodoService: TODOリスト
    TodoService-->>Remix_Client: TODOリスト
    Remix_Client-->>Browser: 画面更新

    Note over Browser,MockData: 2. TODOの完了状態切り替え
    User->>TodoItem: チェックボックスクリック
    TodoItem->>Remix_Client: Form送信
    Remix_Client->>Action: POST /リクエスト
    Action->>Action: handleToggleTodo
    Action->>TodoService: toggleTodo(id)
    TodoService->>MockData: 配列内のTODO状態更新
    TodoService-->>Action: 完了
    Action-->>Remix_Client: JSON応答
    Remix_Client->>TodoService: getTodos()
    TodoService->>MockData: モックデータ取得
    MockData-->>TodoService: TODOリスト
    TodoService-->>Remix_Client: TODOリスト
    Remix_Client-->>Browser: 画面更新

    Note over Browser,MockData: 3. TODOの削除
    User->>TodoItem: 削除ボタンクリック
    TodoItem->>Remix_Client: Form送信
    Remix_Client->>Action: POST /リクエスト
    Action->>Action: handleDeleteTodo
    Action->>TodoService: deleteTodo(id)
    TodoService->>MockData: 配列からTODOを削除
    TodoService-->>Action: 完了
    Action-->>Remix_Client: JSON応答
    Remix_Client->>TodoService: getTodos()
    TodoService->>MockData: モックデータ取得
    MockData-->>TodoService: TODOリスト
    TodoService-->>Remix_Client: TODOリスト
    Remix_Client-->>Browser: 画面更新
```



## ソースコード

```shell:ディレクトリ構成
~/develop/remix_sample  (feat/action)$ tree app/
app/
├── actions
│   └── todoActions.ts
├── components
│   └── todo
│       ├── TodoForm.tsx　# 登録フォーム
│       ├── TodoItem.tsx　# 登録Item
│       └── TodoList.tsx  # 登録リスト
├── entry.client.tsx
├── entry.server.tsx
├── models
│   └── todo.ts
├── root.tsx
├── routes
│   └── _index.tsx # フロント
├── services
│   └── todoService.ts # バックエンド
└── tailwind.css

7 directories, 11 files
```

## ソースコード
```tsx: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 { getTodos } from "~/services/todoService";
import { TodoForm } from "~/components/todo/TodoForm";
import { TodoList } from "~/components/todo/TodoList";
import {
	handleAddTodo,
	handleToggleTodo,
	handleDeleteTodo,
} from "~/actions/todoActions";

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");

	switch (intent) {
		case "add":
			return handleAddTodo(formData);
		case "toggle":
			return handleToggleTodo(formData);
		case "delete":
			return handleDeleteTodo(formData);
		default:
			return json({ error: "Invalid intent" }, { status: 400 });
	}
}

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/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/services/todoService.ts
import type { Todo } from "~/models/todo";

// モックデータをメモリ上で管理
let mockTodos: Todo[] = [
	{ id: 1, title: "Remixを学ぶ", completed: false },
	{ id: 2, title: "TODOアプリを作る", completed: true },
	{ id: 3, title: "コードを理解する", completed: false },
];

export async function getTodos() {
	// APIレスポンスを模倣
	await new Promise((resolve) => setTimeout(resolve, 500));
	return mockTodos;
}

export async function addTodo(title: string) {
	const newTodo: Todo = {
		id: mockTodos.length + 1,
		title,
		completed: false,
	};
	mockTodos = [...mockTodos, newTodo];
	return newTodo;
}

export async function toggleTodo(id: number) {
	mockTodos = mockTodos.map((todo) =>
		todo.id === id ? { ...todo, completed: !todo.completed } : todo,
	);
	return mockTodos.find((todo) => todo.id === id);
}

export async function deleteTodo(id: number) {
	mockTodos = mockTodos.filter((todo) => todo.id !== id);
}

```

```ts:app/components/todo/TodoForm.tsx
import { Form } from "@remix-run/react";

export function TodoForm() {
	return (
		<Form method="post" className="mb-4">
			<div className="flex gap-2">
				<input
					type="text"
					name="title"
					placeholder="新しいTODOを入力"
					className="flex-1 p-2 border rounded"
				/>
				<button
					type="submit"
					name="intent"
					value="add"
					className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
				>
					追加
				</button>
			</div>
		</Form>
	);
}
```

```ts:app/components/todo/TodoItem.tsx
import { Form } from "@remix-run/react";
import type { Todo } from "~/models/todo";

type Props = {
	todo: Todo;
};

export function TodoItem({ todo }: Props) {
	return (
		<li className="flex items-center justify-between p-3 bg-white border rounded shadow-sm">
			<Form method="post" className="flex items-center gap-2">
				<input type="hidden" name="id" value={todo.id} />
				<input type="hidden" name="intent" value="toggle" />
				<input
					type="checkbox"
					checked={todo.completed}
					onChange={(e) => e.currentTarget.form?.submit()}
					className="h-4 w-4"
				/>
				<span className={todo.completed ? "line-through text-gray-500" : ""}>
					{todo.title}
				</span>
			</Form>

			<Form method="post">
				<input type="hidden" name="id" value={todo.id} />
				<button
					type="submit"
					name="intent"
					value="delete"
					className="text-red-500 hover:text-red-700"
				>
					削除
				</button>
			</Form>
		</li>
	);
}

```

```ts:app/components/todo/TodoList.tsx
import type { Todo } from "~/models/todo";
import { TodoItem } from "./TodoItem";

type Props = {
	todos: Todo[];
};

export function TodoList({ todos }: Props) {
	return (
		<ul className="space-y-2">
			{todos.map((todo) => (
				<TodoItem key={todo.id} todo={todo} />
			))}
		</ul>
	);
}
```

## さいごに
Actionも実装すると、だんだんRemixの使い方がわかってきましたね！！