Next.js: 最終プロジェクト: CRUD と Server Actions
最終更新:2026-08-26
タスク管理は SaaS プラットフォームの中心です — 作成、編集、削除、ドラッグアンドドロップによる並べ替え、ファイルアップロード、検索、フィルタリング。包括的な CRUD システムにより、ユーザーはワークフローを効率的に管理できます。
1. 学習内容
- Server Actions を使用したタスク CRUD(作成 / 更新 / 削除)の実装
- Zod バリデーション +
useOptimistic楽観的更新モード useActionState確認ポップアップとエラーハンドリング- ドラッグアンドドロップソートを実装して「未着手」「進行中」「完了」列を切り替え
- Uploadthing ファイルアップロードを Attachment モデルにリンク
- URL 検索パラメーター + shadcn/ui DataTable による検索、フィルタリング、ページネーションの実装
2. あるプロジェクトマネージャーの実話
(1) 課題:50 人のチームがタスク管理に 1 日 3 時間を浪費
Bob は Acme Corp のプロジェクトマネージャーで、5 つのプロジェクトと 200 以上のタスクを管理しています。チームは毎日、タスクの作成(10 フィールドのフォームを繰り返し入力)、ドラッグアンドドロップによるステータス更新(ホワイトボードに付箋を貼るようなもの)、同僚へのファイル依頼(WeChat でやり取り)、タスクの検索(Excel スプレッドシートをスクロール)に多くの時間を費やしています。Alice の計算によると、50 人のチームで 1 人あたり 1 日 36 分を非効率なタスク管理に浪費しており、月間の生産性損失は 600 時間に相当します。
(2) Server Actions + 楽観的更新 + ドラッグアンドドロップのソリューション
Server Actions を使用したゼロリフレッシュ CRUD + 即時フィードバックのための useOptimistic + ドラッグアンドドロップによるステータス切り替え。
// 1 行の Server Action でタスク作成 + キャッシュリフレッシュを完了
<form action={createTask}>
<input name="title" required />
<input name="projectId" type="hidden" value={project.id} />
<button type="submit">タスクを作成</button>
</form>
(3) 成果
| シナリオ | 最適化前 | 最適化後 | 効率改善 |
|---|---|---|---|
| タスク作成 | 3 分(完全フォーム) | 15 秒(クイック作成) | 12x |
| ドラッグアンドドロップソート | 手書きホワイトボード | ステータス列をクリック | 即時 |
| ファイル共有 | WeChat でファイル送信 | Uploadthing でアップロード | 5x |
| タスク検索 | Excel をブラウズ | リアルタイム DataTable 検索 | 20x |
| 月間生産性 | 600 時間の浪費 | ~50 時間の浪費 | 92% 削減 |
3. Server Actions タスク CRUD
(1) 操作アーキテクチャ
graph TB
subgraph "Server Actions"
A[createTask] --> B[Zod バリデート]
B --> C[Prisma 作成]
C --> D[revalidateTag]
D --> E[redirect]
end
subgraph "クライアント"
F[TaskForm] -->|action| A
G[useOptimistic] -->|即時 UI| F
H[useActionState] -->|確認| J[deleteTask]
end
subgraph "キャッシュ"
D --> K[revalidateTag<br/>"tasks"]
K --> L[UI をリフレッシュ]
end
style A fill:#cce5ff
style G fill:#d4edda
style K fill:#ffeeba
(2) タスク操作の比較
| 操作 | Server Action | バリデーション | キャッシュポリシー | UI フィードバック |
|---|---|---|---|---|
| createTask | "use server" |
Zod | revalidateTag("tasks") |
useOptimistic |
| updateTask | "use server" |
Zod 部分的 | revalidateTag("tasks") |
楽観的更新 |
| deleteTask | "use server" |
確認チェック | revalidateTag("tasks") |
useActionState |
| reorderTask | "use server" |
数値検証 | revalidateTag("tasks") |
即時並べ替え |
▶ サンプル: Server Actions 定義
図: createTask; Zod バリデート; Prisma 作成; revalidateTag; redirect; TaskForm。
// src/lib/actions/task-actions.ts
"use server"
import { revalidateTag } from "next/cache"
import { redirect } from "next/navigation"
import { z } from "zod"
import { prisma } from "@/lib/db"
import { auth } from "@/lib/auth"
import { TaskStatus, TaskPriority } from "@prisma/client"
const createTaskSchema = z.object({
title: z.string().min(1, "タイトルは必須です").max(200, "タイトルが長すぎます"),
description: z.string().max(2000).optional(),
projectId: z.string().min(1),
assigneeId: z.string().optional(),
priority: z.nativeEnum(TaskPriority).optional(),
dueDate: z.string().optional(),
})
const updateTaskSchema = z.object({
id: z.string().min(1),
title: z.string().min(1).max(200).optional(),
description: z.string().max(2000).optional(),
status: z.nativeEnum(TaskStatus).optional(),
priority: z.nativeEnum(TaskPriority).optional(),
assigneeId: z.string().nullable().optional(),
dueDate: z.string().nullable().optional(),
order: z.number().int().optional(),
})
export async function createTask(formData: FormData) {
const session = await auth()
if (!session?.user?.id) {
throw new Error("認証されていません")
}
const rawData = {
title: formData.get("title") as string,
description: formData.get("description") as string | undefined,
projectId: formData.get("projectId") as string,
assigneeId: formData.get("assigneeId") as string | undefined,
priority: (formData.get("priority") as TaskPriority) || "MEDIUM",
dueDate: formData.get("dueDate") as string | undefined,
}
const validated = createTaskSchema.safeParse(rawData)
if (!validated.success) {
return {
error: validated.error.flatten().fieldErrors,
message: "バリデーションに失敗しました",
}
}
const data = validated.data
const maxOrder = await prisma.task.findFirst({
where: { projectId: data.projectId, status: "TODO" },
orderBy: { order: "desc" },
select: { order: true },
})
await prisma.task.create({
data: {
title: data.title,
description: data.description || null,
status: "TODO",
priority: data.priority || "MEDIUM",
projectId: data.projectId,
creatorId: session.user.id,
assigneeId: data.assigneeId || null,
dueDate: data.dueDate ? new Date(data.dueDate) : null,
order: (maxOrder?.order ?? -1) + 1,
},
})
revalidateTag("tasks")
revalidateTag(`project-${data.projectId}`)
}
export async function updateTask(formData: FormData) {
const session = await auth()
if (!session?.user?.id) {
throw new Error("認証されていません")
}
const rawData = {
id: formData.get("id") as string,
title: formData.get("title") as string | undefined,
description: formData.get("description") as string | undefined,
status: formData.get("status") as TaskStatus | undefined,
priority: formData.get("priority") as TaskPriority | undefined,
assigneeId: formData.get("assigneeId") as string | null | undefined,
dueDate: formData.get("dueDate") as string | null | undefined,
}
const validated = updateTaskSchema.safeParse(rawData)
if (!validated.success) {
return {
error: validated.error.flatten().fieldErrors,
message: "バリデーションに失敗しました",
}
}
const data = validated.data
const updateData: Record<string, unknown> = {}
if (data.title !== undefined) updateData.title = data.title
if (data.description !== undefined) updateData.description = data.description
if (data.status !== undefined) updateData.status = data.status
if (data.priority !== undefined) updateData.priority = data.priority
if (data.assigneeId !== undefined) updateData.assigneeId = data.assigneeId
if (data.dueDate !== undefined) updateData.dueDate = data.dueDate ? new Date(data.dueDate) : null
if (data.order !== undefined) updateData.order = data.order
await prisma.task.update({
where: { id: data.id },
data: updateData,
})
revalidateTag("tasks")
}
export async function deleteTask(prevState: { message: string }, formData: FormData) {
const session = await auth()
if (!session?.user?.id) {
return { message: "認証されていません" }
}
const taskId = formData.get("id") as string
if (!taskId) {
return { message: "タスク ID は必須です" }
}
const task = await prisma.task.findUnique({
where: { id: taskId },
select: { creatorId: true, projectId: true },
})
if (!task) {
return { message: "タスクが見つかりません" }
}
if (task.creatorId !== session.user.id) {
return { message: "作成者のみがこのタスクを削除できます" }
}
await prisma.task.delete({ where: { id: taskId } })
revalidateTag("tasks")
revalidateTag(`project-${task.projectId}`)
return { message: "タスクが正常に削除されました" }
}
export async function reorderTask(taskId: string, newStatus: TaskStatus, newOrder: number) {
const session = await auth()
if (!session?.user?.id) {
throw new Error("認証されていません")
}
await prisma.task.update({
where: { id: taskId },
data: { status: newStatus, order: newOrder },
})
revalidateTag("tasks")
}
フォームフィールドを受け入れます: title、description、projectId、assigneeId、priority。
処理前に Zod スキーマで入力を検証します。
データベースに書き込み、関連するキャッシュタグを無効化します。
4. 楽観的更新と確認ポップアップ
(1) 3 つの UI モード
graph LR
subgraph "楽観的更新"
A1[ユーザーがクリック] --> B1[即時 UI 更新]
B1 --> C1[Server Action]
C1 --> D1{ロールバック?}
D1 -->|成功| E1[維持]
D1 -->|エラー| F1[元に戻す]
end
subgraph "Action State"
A2[ユーザーが削除をクリック] --> B2[確認ダイアログを表示]
B2 --> C2[確認されたら<br/>deleteTask を呼び出し]
C2 --> D2[結果メッセージを表示]
end
subgraph "従来型"
A3[フォーム送信] --> B3[ローディングスピナー]
B3 --> C3[Server Action]
C3 --> D3[revalidateTag + リフレッシュ]
end
style B1 fill:#d4edda
style B2 fill:#ffeeba
style B3 fill:#f8d7da
▶ サンプル: useOptimistic — 楽観的更新
図は説明されたアーキテクチャ/フローを示しています。
// src/components/tasks/task-list.tsx
"use client"
import { useOptimistic } from "react"
import { TaskCard } from "@/components/tasks/task-card"
import type { TaskWithAssignee } from "@/types"
interface TaskListProps {
tasks: TaskWithAssignee[]
status: "TODO" | "IN_PROGRESS" | "DONE"
onStatusChange: (taskId: string, newStatus: "TODO" | "IN_PROGRESS" | "DONE") => void
}
export function TaskList({ tasks, status, onStatusChange }: TaskListProps) {
const [optimisticTasks, addOptimisticTask] = useOptimistic(
tasks,
(state, newTask: TaskWithAssignee) => {
return [newTask, ...state]
}
)
return (
<div className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
{status.replace("_", " ")}
<span className="ml-2 text-xs">({optimisticTasks.length})</span>
</h3>
{optimisticTasks.length === 0 ? (
<div className="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">
タスクはありません
</div>
) : (
<div className="space-y-2">
{optimisticTasks.map((task) => (
<TaskCard
key={task.id}
task={task}
onStatusChange={onStatusChange}
/>
))}
</div>
)}
</div>
)
}
セクションに記載されている ▶ サンプル: useOptimistic — 楽観的更新 コンポーネントの UI をレンダリングします。
▶ サンプル: useActionState による削除確認
コンポーネントは説明された UI をブラウザにレンダリングします。
// src/components/tasks/delete-task-button.tsx
"use client"
import { useActionState, useState } from "react"
import { deleteTask } from "@/lib/actions/task-actions"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
interface DeleteTaskButtonProps {
taskId: string
taskTitle: string
}
export function DeleteTaskButton({ taskId, taskTitle }: DeleteTaskButtonProps) {
const [open, setOpen] = useState(false)
const [state, formAction, isPending] = useActionState(deleteTask, {
message: "",
})
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-destructive">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</Button>
</DialogTrigger>
<DialogContent>
<form action={formAction}>
<input type="hidden" name="id" value={taskId} />
<DialogHeader>
<DialogTitle>タスクを削除</DialogTitle>
<DialogDescription>
「{taskTitle}」を削除してもよろしいですか?この操作は元に戻せません。
</DialogDescription>
</DialogHeader>
{state.message && state.message !== "タスクが正常に削除されました" && (
<div className="my-4 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{state.message}
</div>
)}
{state.message === "タスクが正常に削除されました" && (
<div className="my-4 rounded-md bg-green-50 p-3 text-sm text-green-700">
{state.message}
</div>
)}
<DialogFooter className="mt-4">
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
>
キャンセル
</Button>
<Button
type="submit"
variant="destructive"
disabled={isPending}
>
{isPending ? "削除中..." : "削除"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
クライアントサイドの状態管理を持つインタラクティブフォーム。
表示コンテンツ: タスクを削除
5. ドラッグアンドドロップソート(3 列カンバン)
(1) カンバン構造
graph LR
subgraph "タスクボード"
A["未着手<br/>(列)"] -->|ドラッグ| B["進行中<br/>(列)"]
B -->|ドラッグ| C["完了<br/>(列)"]
C -->|ドラッグで戻す| B
B -->|ドラッグで戻す| A
end
style A fill:#fef3c7
style B fill:#dbeafe
style C fill:#d1fae5
▶ サンプル: タスクボードの 3 列レイアウト
app ディレクトリ内のレイアウトとテンプレート構造の図。
// src/components/tasks/task-board.tsx
"use client"
import { useCallback } from "react"
import { TaskList } from "@/components/tasks/task-list"
import { reorderTask } from "@/lib/actions/task-actions"
import type { TaskWithAssignee } from "@/types"
interface TaskBoardProps {
tasks: TaskWithAssignee[]
}
export function TaskBoard({ tasks }: TaskBoardProps) {
const todoTasks = tasks.filter((t) => t.status === "TODO")
const inProgressTasks = tasks.filter((t) => t.status === "IN_PROGRESS")
const doneTasks = tasks.filter((t) => t.status === "DONE")
const handleStatusChange = useCallback(
async (taskId: string, newStatus: "TODO" | "IN_PROGRESS" | "DONE") => {
const targetList = tasks.filter((t) => t.status === newStatus)
const maxOrder = targetList.length > 0
? Math.max(...targetList.map((t) => t.order))
: 0
try {
await reorderTask(taskId, newStatus, maxOrder + 1)
} catch (error) {
console.error("タスクの並べ替えに失敗しました:", error)
}
},
[tasks]
)
return (
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
<div className="rounded-lg bg-muted/50 p-4">
<TaskList
tasks={todoTasks}
status="TODO"
onStatusChange={handleStatusChange}
/>
</div>
<div className="rounded-lg bg-muted/50 p-4">
<TaskList
tasks={inProgressTasks}
status="IN_PROGRESS"
onStatusChange={handleStatusChange}
/>
</div>
<div className="rounded-lg bg-muted/50 p-4">
<TaskList
tasks={doneTasks}
status="DONE"
onStatusChange={handleStatusChange}
/>
</div>
</div>
)
}
セクションに記載されている ▶ サンプル: タスクボードの 3 列レイアウト コンポーネントの UI をレンダリングします。
▶ サンプル: タスクカードコンポーネント
コンポーネントは説明された UI をブラウザにレンダリングします。
// src/components/tasks/task-card.tsx
"use client"
import { DeleteTaskButton } from "@/components/tasks/delete-task-button"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import type { TaskWithAssignee } from "@/types"
interface TaskCardProps {
task: TaskWithAssignee
onStatusChange: (taskId: string, newStatus: "TODO" | "IN_PROGRESS" | "DONE") => void
}
const statusActions: Record<string, ("TODO" | "IN_PROGRESS" | "DONE")[]> = {
TODO: ["IN_PROGRESS"],
IN_PROGRESS: ["TODO", "DONE"],
DONE: ["IN_PROGRESS"],
}
const priorityColors: Record<string, string> = {
LOW: "bg-gray-100 text-gray-700",
MEDIUM: "bg-blue-100 text-blue-700",
HIGH: "bg-orange-100 text-orange-700",
URGENT: "bg-red-100 text-red-700",
}
export function TaskCard({ task, onStatusChange }: TaskCardProps) {
const initials = task.assignee?.name
?.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
return (
<div className="group rounded-lg border bg-card p-3 shadow-sm transition-all hover:shadow-md">
<div className="flex items-start justify-between">
<h4 className="text-sm font-medium">{task.title}</h4>
<DeleteTaskButton taskId={task.id} taskTitle={task.title} />
</div>
{task.description && (
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
{task.description}
</p>
)}
<div className="mt-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge
variant="secondary"
className={priorityColors[task.priority]}
>
{task.priority}
</Badge>
{task.dueDate && (
<span className="text-xs text-muted-foreground">
{new Date(task.dueDate).toLocaleDateString("ja-JP", {
month: "short",
day: "numeric",
})}
</span>
)}
</div>
<div className="flex items-center gap-2">
<div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
{statusActions[task.status].map((action) => (
<button
key={action}
onClick={() => onStatusChange(task.id, action)}
className="rounded px-2 py-1 text-[10px] font-medium uppercase transition-colors hover:bg-accent"
>
{action === "TODO"
? "\u21A9"
: action === "IN_PROGRESS"
? "\u25B6"
: "\u2713"}
</button>
))}
</div>
{task.assignee && (
<Avatar className="h-6 w-6">
<AvatarImage src={task.assignee.image ?? undefined} />
<AvatarFallback className="text-[10px]">
{initials}
</AvatarFallback>
</Avatar>
)}
</div>
</div>
{task._count?.comments > 0 && (
<div className="mt-2 flex items-center gap-1 text-xs text-muted-foreground">
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
{task._count.comments}
</div>
)}
</div>
)
}
セクションに記載されている ▶ サンプル: タスクカードコンポーネント コンポーネントの UI をレンダリングします。
6. ファイルアップロード(uploadthing)
(1) アップロードアーキテクチャ
sequenceDiagram
participant C as クライアント
participant U as UploadThing
participant S as Server Action
participant DB as データベース
C->>U: ファイルをアップロード (multipart)
U-->>C: fileUrl + メタデータを返す
C->>S: Server Action (fileUrl を含む formData)
S->>DB: Attachment レコードを作成
DB-->>S: Attachment が作成されました
S->>S: revalidateTag("tasks")
S-->>C: 成功
▶ サンプル: UploadThing 設定
参加者間のメッセージフローを示すシーケンス図。
// src/lib/uploadthing.ts
import { createUploadthing, type FileRouter } from "uploadthing/next"
import { auth } from "@/lib/auth"
const f = createUploadthing()
export const ourFileRouter = {
taskAttachment: f({
image: { maxFileSize: "4MB", maxFileCount: 5 },
pdf: { maxFileSize: "8MB", maxFileCount: 3 },
"text/*": { maxFileSize: "2MB", maxFileCount: 5 },
})
.middleware(async () => {
const session = await auth()
if (!session?.user) throw new Error("認証されていません")
return { userId: session.user.id }
})
.onUploadComplete(async ({ metadata, file }) => {
return { uploadedBy: metadata.userId, fileUrl: file.url }
}),
} satisfies FileRouter
export type OurFileRouter = typeof ourFileRouter
TypeScript 型を定義します: FileRouter、OurFileRouter。
// src/app/api/uploadthing/core.ts
import { createRouteHandler } from "uploadthing/next"
import { ourFileRouter } from "@/lib/uploadthing"
export const { GET, POST } = createRouteHandler({
router: ourFileRouter,
})
7. 検索、フィルタリング、ページネーション
(1) URL 検索パラメーター構造
graph LR
A[URL 検索パラメーター] --> B[サーバーコンポーネント]
B --> C[Prisma クエリ]
C --> D[フィルタリング結果]
D --> E[shadcn/ui DataTable]
E -->|ユーザー操作| F[URL パラメーターを更新]
F -->|Next.js ナビゲーション| A
style A fill:#cce5ff
style E fill:#d4edda
style F fill:#ffeeba
8. 完全な例: 完全なタスクボードページ
// src/app/(dashboard)/dashboard/projects/[id]/page.tsx
import { Suspense } from "react"
import { prisma } from "@/lib/db"
import { auth } from "@/lib/auth"
import { notFound } from "next/navigation"
import { TaskBoard } from "@/components/tasks/task-board"
import { CreateTaskForm } from "@/components/tasks/create-task-form"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
interface ProjectBoardPageProps {
params: Promise<{ id: string }>
}
export default async function ProjectBoardPage({ params }: ProjectBoardPageProps) {
const { id } = await params
const session = await auth()
const project = await prisma.project.findUnique({
where: { id },
include: {
_count: { select: { tasks: true } },
},
})
if (!project) {
notFound()
}
const tasks = await prisma.task.findMany({
where: { projectId: id },
orderBy: { order: "asc" },
include: {
assignee: { select: { name: true, image: true } },
_count: { select: { comments: true, attachments: true } },
},
})
const statusCounts = {
TODO: tasks.filter((t) => t.status === "TODO").length,
IN_PROGRESS: tasks.filter((t) => t.status === "IN_PROGRESS").length,
DONE: tasks.filter((t) => t.status === "DONE").length,
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-3">
<div
className="h-4 w-4 rounded-full"
style={{ backgroundColor: project.color }}
/>
<h1 className="text-2xl font-bold">{project.name}</h1>
<Badge variant="secondary">
{project.status}
</Badge>
</div>
<p className="mt-1 text-sm text-muted-foreground">
{tasks.length} タスク · {statusCounts.TODO} 未着手 ·{" "}
{statusCounts.IN_PROGRESS} 進行中 · {statusCounts.DONE} 完了
</p>
</div>
</div>
<Suspense fallback={<div className="h-12 animate-pulse rounded-lg bg-muted" />}>
<CreateTaskForm projectId={project.id} members={[]} />
</Suspense>
<TaskBoard tasks={tasks} />
</div>
)
}
ブラウザで /dashboard/projects/[id] にアクセスした場合の期待される出力:
→ 3 列カンバンボード: TODO (8) | IN PROGRESS (5) | DONE (7)
→ 「+」ボタンをクリックしてクイック作成フォームをポップアップ → タイトルを入力 → Enter → タスクが TODO 列に即座に表示(楽観的更新)
→ タスクカードの ▶ ボタンをクリック → タスクを IN PROGRESS に移動
→ 削除をクリック → 確認ダイアログを表示 → 削除を確認 → タスクが消える
→ ファイルをアップロード領域にドラッグアンドドロップ → プログレスバーを表示 → 添付ファイルがタスクに関連付けられる
→ "auth" を検索 → URL パラメーターが更新 → DataTable が一致するタスクをフィルタリングして表示
❓ よくある質問
useOptimistic と通常の useState の違いは何ですか?useOptimistic は React 19 で導入された新しいフックで、Server Actions と共に使用する楽観的更新パターン専用に設計されています。Server Action の実行中に UI を即座に更新し、Server Action が失敗した場合は自動的に実際のデータにロールバックします。useState で手動ロールバックを行うと、より多くのボイラープレートコードが必要になり、エラーが発生しやすくなります。/uploads/ ソリューションは実装が簡単ですが、本番環境には適していません(CDN なし、ストレージ容量が限られている、バックアップが困難です)。TaskFlow では、開発ソリューションとしてローカルアップロードを使用し、本番環境では uploadthing に切り替えます。safeParse を提供し、型エラーメッセージを自動生成するため、手動の if-else バリデーションよりも信頼性が高くなります。deleteTask を直接呼び出すのではなく useActionState を使用するのはなぜですか?useActionState は React 19 で Server Actions 用に設計されたフックです。isPending 状態(ボタンを無効化して重複送信を防止)と action ラッパー関数(formData を自動処理)を提供します。直接呼び出しと比較して、useActionState はより安全で(CSRF 保護)、より自然な UI フィードバックを提供します。📖 まとめ
- Server Actions:
"use server"ディレクティブを使用してサーバー上で直接関数を実行し、手動の API ルートを回避します。 - Zod は型安全なフォームバリデーションを提供し、Server Actions のエラーハンドリングと組み合わせて完全なバリデーションワークフローを実現します。
useOptimisticはリアルタイムの UI 更新を可能にし、Server Action が失敗した場合は自動的にロールバックします。useActionStateは確認ポップアップの状態を管理し、isPendingと自動 formData ラップを提供します。- 3 列カンバンボードは
reorderTaskServer Action を介してドラッグアンドドロップのステータス切り替えを実装します。 - Uploadthing + ローカル
/uploads/のデュアル方式のファイルアップロードで、画像、PDF、テキストをサポートします。 - URL 検索パラメーターは真のページネーション、検索、フィルタリングを可能にし、DataTable は 7 列にわたる完全なタスク情報を表示します。
revalidateTag("tasks")はキャッシュ無効化戦略を標準化し、CRUD 操作後のリアルタイム UI 更新を保証します。
📝 練習問題
-
基本問題 (⭐): TaskFlow プロジェクトに Server Action と楽観的更新を使用したクイックタスク作成フォーム(タイトル + projectId)を実装します。検証: 「作成」をクリックすると、タスクがカンバンボードの TODO 列に即座に表示されること。
-
応用問題 (⭐⭐): DataTable に「一括操作」機能を追加します — 複数行を選択した後、それらのステータスを一括更新する(TODO → IN_PROGRESS)か一括削除します。
useOptimisticを使用して一括選択の楽観的更新を実装します。配列パラメーターを処理するbulkUpdateTasksServer Action を記述します。 -
発展問題 (⭐⭐⭐): 完全なドラッグアンドドロップソートを実装します(
@dnd-kit/coreまたはネイティブ HTML5 Drag & Drop API を使用): タスクカードを 3 つの列間で自由にドラッグできるようにする。ドラッグ中にプレースホルダーを表示する。カードを離した後にreorderTaskを呼び出してステータスとソート順を更新する。useOptimisticを使用してドラッグ後に即時のソートフィードバックを提供する。ドラッグの競合を処理する(2 人のユーザーが同時に同じタスクをドラッグした場合の楽観的ロック)。