はじめに
前回までの続きです。
今回はアクションを実装し、TODOの新規追加や更新、削除の実装をしていきます!
シリーズもの
-
Step 1: 基本的なセットアップとTailwindCSS導入
Remixプロジェクトの作成、TailwindCSSのセットアップ、静的なTODOリストの表示 -
Step 2: バックエンドとの疎通
Remixのloader関数を使ったデータフェッチ、型定義の作成、ローディング状態の実装 -
Step 3: アクションの実装
TODOの追加・更新・削除機能の実装、コンポーネントの分割とリファクタリング -
Step 4: データベース連携
PrismaとSQLiteを使用したデータの永続化、マイグレーションの実行、CRUD操作の実装
成果物
ソースコード
ディレクトリ構成
~/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
ソースコード
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>
);
}
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 });
}
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);
}
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>
);
}
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>
);
}
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の使い方がわかってきましたね!!
