Next.js: 最終プロジェクト: CRUD と Server Actions

最終更新:2026-08-26

タスク管理は SaaS プラットフォームの中心です — 作成、編集、削除、ドラッグアンドドロップによる並べ替え、ファイルアップロード、検索、フィルタリング。包括的な CRUD システムにより、ユーザーはワークフローを効率的に管理できます。

1. 学習内容



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 + ドラッグアンドドロップによるステータス切り替え。

TSX
// 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) 操作アーキテクチャ

100%
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 定義

💻 出力:

TEXT 📖 参照専用
図: createTask; Zod バリデート; Prisma 作成; revalidateTag; redirect; TaskForm。
TYPESCRIPT
// 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")
}
💻 出力:

TEXT 📖 参照専用
フォームフィールドを受け入れます: title、description、projectId、assigneeId、priority。
処理前に Zod スキーマで入力を検証します。
データベースに書き込み、関連するキャッシュタグを無効化します。


4. 楽観的更新と確認ポップアップ

(1) 3 つの UI モード

100%
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 — 楽観的更新

💻 出力:

TEXT 📖 参照専用
図は説明されたアーキテクチャ/フローを示しています。
TSX
// 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>
  )
}
💻 出力:

TEXT 📖 参照専用
セクションに記載されている ▶ サンプル: useOptimistic — 楽観的更新 コンポーネントの UI をレンダリングします。

▶ サンプル: useActionState による削除確認

💻 出力:

TEXT 📖 参照専用
コンポーネントは説明された UI をブラウザにレンダリングします。
TSX
// 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>
  )
}
💻 出力:

TEXT 📖 参照専用
クライアントサイドの状態管理を持つインタラクティブフォーム。
表示コンテンツ: タスクを削除


5. ドラッグアンドドロップソート(3 列カンバン)

(1) カンバン構造

100%
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 列レイアウト

💻 出力:

TEXT 📖 参照専用
app ディレクトリ内のレイアウトとテンプレート構造の図。
TSX
// 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>
  )
}
💻 出力:

TEXT 📖 参照専用
セクションに記載されている ▶ サンプル: タスクボードの 3 列レイアウト コンポーネントの UI をレンダリングします。

▶ サンプル: タスクカードコンポーネント

💻 出力:

TEXT 📖 参照専用
コンポーネントは説明された UI をブラウザにレンダリングします。
TSX
// 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>
  )
}
💻 出力:

TEXT 📖 参照専用
セクションに記載されている ▶ サンプル: タスクカードコンポーネント コンポーネントの UI をレンダリングします。


6. ファイルアップロード(uploadthing)

(1) アップロードアーキテクチャ

100%
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 設定

💻 出力:

TEXT 📖 参照専用
参加者間のメッセージフローを示すシーケンス図。
TYPESCRIPT
// 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
💻 出力:

TEXT 📖 参照専用
TypeScript 型を定義します: FileRouter、OurFileRouter。
TYPESCRIPT
// 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 検索パラメーター構造

100%
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. 完全な例: 完全なタスクボードページ

TSX
// 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] にアクセスした場合の期待される出力:

TEXT 📖 参照専用
→ 3 列カンバンボード: TODO (8) | IN PROGRESS (5) | DONE (7)
→ 「+」ボタンをクリックしてクイック作成フォームをポップアップ → タイトルを入力 → Enter → タスクが TODO 列に即座に表示(楽観的更新)
→ タスクカードの ▶ ボタンをクリック → タスクを IN PROGRESS に移動
→ 削除をクリック → 確認ダイアログを表示 → 削除を確認 → タスクが消える
→ ファイルをアップロード領域にドラッグアンドドロップ → プログレスバーを表示 → 添付ファイルがタスクに関連付けられる
→ "auth" を検索 → URL パラメーターが更新 → DataTable が一致するタスクをフィルタリングして表示

❓ よくある質問

Q Server Actions と API Routes のどちらを選ぶべきですか?
A フォーム送信とキャッシュリフレッシュには Server Actions を使用します(コードが少なく、型安全で、API エンドポイントが不要です)。サードパーティ統合、webhook、モバイル API には API Routes を使用します。Server Actions は Next.js でフォームを処理する推奨方法であり、API Routes は非ブラウザクライアント向けに使用します。
Q useOptimistic と通常の useState の違いは何ですか?
A useOptimistic は React 19 で導入された新しいフックで、Server Actions と共に使用する楽観的更新パターン専用に設計されています。Server Action の実行中に UI を即座に更新し、Server Action が失敗した場合は自動的に実際のデータにロールバックします。useState で手動ロールバックを行うと、より多くのボイラープレートコードが必要になり、エラーが発生しやすくなります。
Q uploadthing とローカルファイルアップロードのどちらが優れていますか?
A uploadthing は CDN、画像最適化、セキュリティ検証を提供し、本番環境に適しています。ローカルの /uploads/ ソリューションは実装が簡単ですが、本番環境には適していません(CDN なし、ストレージ容量が限られている、バックアップが困難です)。TaskFlow では、開発ソリューションとしてローカルアップロードを使用し、本番環境では uploadthing に切り替えます。
Q URL 検索パラメーターのページネーションでクライアントサイドページネーションを使用しないのはなぜですか?
A URL 検索パラメーターのページネーションは「真のページネーション」(サーバーサイドページネーション)であり、現在のページのデータのみを読み込むため、大規模データセットに適しています。クライアントサイドページネーションは全データを一度に読み込むため、初期読み込みが遅く、帯域幅を浪費します。TaskFlow には数万のタスクが存在する可能性があるため、サーバーサイドページネーションが唯一の現実的な選択肢です。
Q Server Action における Zod バリデーションの役割は何ですか?
A 二重の保護層を提供します。ブラウザサイドバリデーション(HTML5 required + フロントエンドバリデーション)は誤操作を防ぎ、サーバーサイドの Zod バリデーションは悪意のあるリクエストを防ぎます。また、Zod は型安全な safeParse を提供し、型エラーメッセージを自動生成するため、手動の if-else バリデーションよりも信頼性が高くなります。
Q タスク削除に deleteTask を直接呼び出すのではなく useActionState を使用するのはなぜですか?
A useActionState は React 19 で Server Actions 用に設計されたフックです。isPending 状態(ボタンを無効化して重複送信を防止)と action ラッパー関数(formData を自動処理)を提供します。直接呼び出しと比較して、useActionState はより安全で(CSRF 保護)、より自然な UI フィードバックを提供します。

📖 まとめ

📝 練習問題

  1. 基本問題 (⭐): TaskFlow プロジェクトに Server Action と楽観的更新を使用したクイックタスク作成フォーム(タイトル + projectId)を実装します。検証: 「作成」をクリックすると、タスクがカンバンボードの TODO 列に即座に表示されること。

  2. 応用問題 (⭐⭐): DataTable に「一括操作」機能を追加します — 複数行を選択した後、それらのステータスを一括更新する(TODO → IN_PROGRESS)か一括削除します。useOptimistic を使用して一括選択の楽観的更新を実装します。配列パラメーターを処理する bulkUpdateTasks Server Action を記述します。

  3. 発展問題 (⭐⭐⭐): 完全なドラッグアンドドロップソートを実装します(@dnd-kit/core またはネイティブ HTML5 Drag & Drop API を使用): タスクカードを 3 つの列間で自由にドラッグできるようにする。ドラッグ中にプレースホルダーを表示する。カードを離した後に reorderTask を呼び出してステータスとソート順を更新する。useOptimistic を使用してドラッグ後に即時のソートフィードバックを提供する。ドラッグの競合を処理する(2 人のユーザーが同時に同じタスクをドラッグした場合の楽観的ロック)。

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%