メインコンテンツまでスキップ

フルスタック実践プロジェクト

この章では、これまで学んだすべての知識を統合し、React + NestJS + Prismaによるプロジェクト管理アプリ(Trelloライク)を構築します。

この章で学ぶこと

  • React + NestJS + Prismaを組み合わせたフルスタックアプリ構築
  • @dnd-kitによるドラッグ&ドロップ機能の実装
  • TanStack Queryを活用した効率的なサーバー状態管理
  • 実践的なプロジェクト設計とコンポーネント分割
備考

本章では、本書で扱った React / TypeScript / Tailwind CSS / 状態管理 (Zustand / TanStack Query) / React Router / 認証 / NestJS + Prisma を組み合わせて使います。これまでの学習で使用した 03 章のプロジェクトとは別に、新しいフルスタックプロジェクトを作成します。実際に動くアプリケーションを構築する中で、各技術がどのように連携するかを体感できます。完成後は「発展課題」に取り組んでみてください。

プロジェクト概要

機能一覧

機能説明
ユーザー認証登録、ログイン、ログアウト
プロジェクト管理作成、編集、削除
ボード管理プロジェクト内のボード(リスト)管理
カード管理ボード内のタスクカード管理
ドラッグ&ドロップカードとボードの並び替え

技術スタック

フロントエンド:

  • React + TypeScript
  • Vite
  • Tailwind CSS
  • Zustand(認証状態)
  • TanStack Query(サーバー状態)
  • React Router
  • @dnd-kit(ドラッグ&ドロップ)
なぜ@dnd-kitを選ぶのか

Reactでドラッグ&ドロップを実装するライブラリは複数ありますが、@dnd-kitは以下の理由で選びました:(1) アクセシビリティを標準でサポート、(2) TypeScript完全対応、(3) 軽量でパフォーマンスが良い、(4) カスタマイズ性が高い。以前よく使われていたreact-beautiful-dndは、2021年10月にREADMEへメンテナンス縮小の免責が追加され、2024年10月にnpm上で非推奨(deprecated)化、その後リポジトリもアーカイブされたため、新規プロジェクトでは@dnd-kitが推奨されます。

バックエンド:

  • NestJS
  • Prisma + PostgreSQL
  • JWT認証

フロントエンドセットアップ

# プロジェクト作成
npm create vite@latest project-app -- --template react-ts
cd project-app

# 依存関係のインストール
npm install zustand @tanstack/react-query axios react-router
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
npm install clsx
npm install tailwindcss @tailwindcss/vite
npm install -D @types/node

Tailwind CSS v4 は、vite.config.tsimport tailwindcss from '@tailwindcss/vite' を追加して pluginstailwindcss() を登録します。さらに src/index.css の先頭に @import "tailwindcss"; を書けばセットアップ完了です。セットアップの詳細は 17 章 Tailwind CSS を参照してください。

プロジェクト構成

project-app/
├── src/
│ ├── components/
│ │ ├── layout/
│ │ │ ├── Header.tsx
│ │ │ └── Layout.tsx
│ │ ├── auth/
│ │ │ ├── LoginForm.tsx
│ │ │ ├── RegisterForm.tsx
│ │ │ └── PrivateRoute.tsx
│ │ ├── projects/
│ │ │ ├── ProjectCard.tsx
│ │ │ ├── ProjectForm.tsx
│ │ │ └── ProjectList.tsx
│ │ └── board/
│ │ ├── Board.tsx
│ │ ├── BoardColumn.tsx
│ │ ├── Card.tsx
│ │ └── CardForm.tsx
│ ├── hooks/
│ │ ├── useAuth.ts
│ │ ├── useProjects.ts
│ │ └── useBoards.ts
│ ├── stores/
│ │ └── authStore.ts
│ ├── lib/
│ │ └── api.ts
│ ├── types/
│ │ └── index.ts
│ ├── pages/
│ │ ├── LoginPage.tsx
│ │ ├── RegisterPage.tsx
│ │ ├── DashboardPage.tsx
│ │ └── ProjectPage.tsx
│ ├── App.tsx
│ └── main.tsx
├── .env
└── package.json
警告

本章のフロントエンドが呼ぶボード・カードの API(POST /projects/:id/boardsPOST /boards/:id/cardsPATCH /cards/:id/position など)は、28 章では実装していません。28 章の Projects モジュールと同じパターン(controller / service / DTO)で Boards・Cards モジュールを自作してから本章に進んでください。これ自体が 28 章の総合演習です。

エントリーポイントとルーティング

main.tsx では、TanStack Query の QueryClientProvider と React Router の BrowserRouter を設定します。

// src/main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App'
import './index.css'

const queryClient = new QueryClient()

createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</StrictMode>
)

App.tsx・認証まわり(AuthProvider / PrivateRoute / ログイン・登録フォーム / stores/authStore.ts)・src/lib/api.ts(Axios インターセプター)は、27 章で作成したものを流用します。ルート定義には、プロジェクト詳細ページ用に /projects/:projectId を追加してください。

// src/App.tsx(27 章の Routes に追加)
<Route
path="/projects/:projectId"
element={
<PrivateRoute>
<ProjectPage />
</PrivateRoute>
}
/>

型定義

// src/types/index.ts
export type User = {
id: string
email: string
name: string
}

export type Project = {
id: string
name: string
description: string | null
ownerId: string
owner: User
boards?: Board[] // 詳細 API でのみ含まれる
_count?: { boards: number } // 一覧 API はボード件数のみ返す
createdAt: string
updatedAt: string
}

export type Board = {
id: string
name: string
position: number
projectId: string
cards: Card[]
createdAt: string
updatedAt: string
}

export type Card = {
id: string
title: string
description: string | null
position: number
boardId: string
createdAt: string
updatedAt: string
}

プロジェクト一覧ページ

// src/pages/DashboardPage.tsx
import { useState } from 'react'
import { Link } from 'react-router'
import { useProjects, useCreateProject } from '../hooks/useProjects'
import { ProjectCard } from '../components/projects/ProjectCard'
import { ProjectForm } from '../components/projects/ProjectForm'

export function DashboardPage() {
const [isFormOpen, setIsFormOpen] = useState(false)
const { data: projects, isLoading } = useProjects()
const createProject = useCreateProject()

const handleOpenForm = () => {
setIsFormOpen(true)
}

const handleCloseForm = () => {
setIsFormOpen(false)
}

const handleCreate = (data: { name: string; description?: string }) => {
createProject.mutate(data, {
onSuccess: () => setIsFormOpen(false)
})
}

if (isLoading) {
return (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
</div>
)
}

return (
<div className="max-w-6xl mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-8">
<h1 className="text-2xl font-bold">プロジェクト</h1>
<button
onClick={handleOpenForm}
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
>
新規プロジェクト
</button>
</div>

{projects?.length === 0 ? (
<div className="text-center py-12 text-gray-500">
<p>プロジェクトがありません</p>
<p className="text-sm mt-2">「新規プロジェクト」から作成してください</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{projects?.map((project) => (
<Link key={project.id} to={`/projects/${project.id}`}>
<ProjectCard project={project} />
</Link>
))}
</div>
)}

{isFormOpen && (
<ProjectForm
onSubmit={handleCreate}
onClose={handleCloseForm}
isLoading={createProject.isPending}
/>
)}
</div>
)
}

プロジェクト作成フォーム

DashboardPage から呼び出す、新規プロジェクト作成用のモーダルフォームです。

// src/components/projects/ProjectForm.tsx
import { useState } from 'react'

type ProjectFormProps = {
onSubmit: (data: { name: string; description?: string }) => void
onClose: () => void
isLoading: boolean
}

export function ProjectForm({ onSubmit, onClose, isLoading }: ProjectFormProps) {
const [name, setName] = useState('')
const [description, setDescription] = useState('')

const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setName(e.target.value)
}

const handleDescriptionChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setDescription(e.target.value)
}

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) return

onSubmit({
name: name.trim(),
description: description.trim() || undefined
})
}

return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">新規プロジェクト</h2>
<button onClick={onClose} className="text-gray-500 hover:text-gray-700">

</button>
</div>

<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">プロジェクト名</label>
<input
type="text"
value={name}
onChange={handleNameChange}
autoFocus
className="w-full px-3 py-2 border rounded"
/>
</div>

<div>
<label className="block text-sm font-medium mb-1">説明(任意)</label>
<textarea
value={description}
onChange={handleDescriptionChange}
rows={3}
className="w-full px-3 py-2 border rounded"
/>
</div>

<div className="flex justify-end gap-2">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-gray-600 hover:text-gray-800"
>
キャンセル
</button>
<button
type="submit"
disabled={isLoading}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
>
作成
</button>
</div>
</form>
</div>
</div>
)
}

プロジェクトカード

// src/components/projects/ProjectCard.tsx
import { Project } from '../../types'

type ProjectCardProps = {
project: Project
}

export function ProjectCard({ project }: ProjectCardProps) {
return (
<div className="bg-white rounded-lg shadow p-6 hover:shadow-lg transition-shadow">
<h3 className="text-lg font-semibold mb-2">{project.name}</h3>
{project.description && (
<p className="text-gray-600 text-sm mb-4 line-clamp-2">
{project.description}
</p>
)}
<div className="flex items-center justify-between text-sm text-gray-500">
<span>{project._count?.boards ?? project.boards?.length ?? 0} ボード</span>
<span>{new Date(project.createdAt).toLocaleDateString('ja-JP')}</span>
</div>
</div>
)
}

プロジェクト詳細ページ(カンバンボード)

// src/pages/ProjectPage.tsx
import { useState, useEffect } from 'react'
import { useParams } from 'react-router'
import {
DndContext,
DragOverlay,
closestCorners,
PointerSensor,
useSensor,
useSensors,
DragStartEvent,
DragEndEvent,
DragOverEvent
} from '@dnd-kit/core'
import { arrayMove } from '@dnd-kit/sortable'
import { useProject } from '../hooks/useProjects'
import { useCreateBoard, useUpdateCardPosition } from '../hooks/useBoards'
import { BoardColumn } from '../components/board/BoardColumn'
import { Card } from '../components/board/Card' // Cardコンポーネント
import { Card as CardType, Board } from '../types' // Card型(名前衝突を回避)

export function ProjectPage() {
const { projectId } = useParams<{ projectId: string }>()
const { data: project, isLoading } = useProject(projectId!)
const updateCardPosition = useUpdateCardPosition()

const [activeCard, setActiveCard] = useState<CardType | null>(null)
const [boards, setBoards] = useState<Board[]>([])

// プロジェクトデータが変わったらボードを更新
useEffect(() => {
if (project?.boards) {
setBoards(project.boards)
}
}, [project])

const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8
}
})
)

const handleDragStart = (event: DragStartEvent) => {
const { active } = event
const activeData = active.data.current

if (activeData?.type === 'card') {
setActiveCard(activeData.card)
}
}

const handleDragOver = (event: DragOverEvent) => {
const { active, over } = event

if (!over) return

const activeData = active.data.current
const overData = over.data.current

if (activeData?.type !== 'card') return

const activeCardId = active.id as string
const activeCard = activeData.card as CardType
const activeBoardId = activeCard.boardId

// カードを別のボードに移動
let overBoardId: string

if (overData?.type === 'card') {
overBoardId = (overData.card as CardType).boardId
} else if (overData?.type === 'board') {
overBoardId = over.id as string
} else {
return
}

if (activeBoardId === overBoardId) return

setBoards((prevBoards) => {
const activeBoard = prevBoards.find((b) => b.id === activeBoardId)
const overBoard = prevBoards.find((b) => b.id === overBoardId)

if (!activeBoard || !overBoard) return prevBoards

const activeCardIndex = activeBoard.cards.findIndex(
(c) => c.id === activeCardId
)
const movedCard = activeBoard.cards[activeCardIndex]
if (!movedCard) return prevBoards

let overCardIndex = overBoard.cards.length

if (overData?.type === 'card') {
overCardIndex = overBoard.cards.findIndex(
(c) => c.id === (over.id as string)
)
}

// state を直接変更せず、各 cards 配列を新規作成してイミュータブルに更新
return prevBoards.map((board) => {
if (board.id === activeBoardId) {
return {
...board,
cards: board.cards.filter((_, i) => i !== activeCardIndex)
}
}
if (board.id === overBoardId) {
const newCards = [...board.cards]
newCards.splice(overCardIndex, 0, { ...movedCard, boardId: overBoardId })
return { ...board, cards: newCards }
}
return board
})
})
}

const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event

setActiveCard(null)

if (!over) return

const activeData = active.data.current
const overData = over.data.current

if (activeData?.type === 'card' && overData?.type === 'card') {
const activeCard = activeData.card as CardType
const overCard = overData.card as CardType

if (activeCard.boardId === overCard.boardId) {
// 同じボード内での並び替え
const board = boards.find((b) => b.id === activeCard.boardId)
if (!board) return

const oldIndex = board.cards.findIndex((c) => c.id === activeCard.id)
const newIndex = board.cards.findIndex((c) => c.id === overCard.id)

// サーバーに更新を送信
// (updater 関数は純粋に保つ。内側で呼ぶと StrictMode の二重実行で PATCH が二重送信される)
updateCardPosition.mutate({
cardId: activeCard.id,
boardId: activeCard.boardId,
position: newIndex
})

// state を直接変更せず、対象ボードの cards のみ新配列に差し替える
setBoards((prevBoards) =>
prevBoards.map((b) =>
b.id === activeCard.boardId
? { ...b, cards: arrayMove(b.cards, oldIndex, newIndex) }
: b
)
)
} else {
// 別ボードへの移動(handleDragOverで処理済み)
updateCardPosition.mutate({
cardId: activeCard.id,
boardId: overCard.boardId,
position: 0
})
}
}
}

if (isLoading) {
return (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
</div>
)
}

if (!project) {
return <div className="text-center py-12">プロジェクトが見つかりません</div>
}

return (
<div className="h-full overflow-hidden">
<div className="px-4 py-4 border-b">
<h1 className="text-xl font-bold">{project.name}</h1>
{project.description && (
<p className="text-gray-600 text-sm mt-1">{project.description}</p>
)}
</div>

<div className="h-[calc(100vh-120px)] overflow-x-auto p-4">
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<div className="flex gap-4 h-full">
{boards.map((board) => (
<BoardColumn
key={board.id}
board={board}
projectId={project.id}
/>
))}

<AddBoardButton projectId={project.id} />
</div>

<DragOverlay>
{activeCard && <Card card={activeCard} isDragging />}
</DragOverlay>
</DndContext>
</div>
</div>
)
}

function AddBoardButton({ projectId }: { projectId: string }) {
const [isAdding, setIsAdding] = useState(false)
const [name, setName] = useState('')
const { mutate: createBoard, isPending } = useCreateBoard()

const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setName(e.target.value)
}

const handleStartAdding = () => {
setIsAdding(true)
}

const handleCancel = () => {
setIsAdding(false)
}

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) return

createBoard(
{ projectId, name: name.trim() },
{
onSuccess: () => {
setName('')
setIsAdding(false)
}
}
)
}

if (isAdding) {
return (
<div className="w-72 shrink-0 bg-gray-100 rounded-lg p-3">
<form onSubmit={handleSubmit}>
<input
type="text"
value={name}
onChange={handleNameChange}
placeholder="ボード名を入力..."
autoFocus
className="w-full px-3 py-2 rounded border focus:outline-hidden focus:ring-2 focus:ring-blue-500"
/>
<div className="flex gap-2 mt-2">
<button
type="submit"
disabled={isPending}
className="px-3 py-1.5 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
>
追加
</button>
<button
type="button"
onClick={handleCancel}
className="px-3 py-1.5 text-gray-600 hover:text-gray-800"
>
キャンセル
</button>
</div>
</form>
</div>
)
}

return (
<button
onClick={handleStartAdding}
className="w-72 shrink-0 h-12 bg-gray-200 hover:bg-gray-300 rounded-lg
flex items-center justify-center text-gray-600 transition-colors"
>
+ ボードを追加
</button>
)
}

ボードカラム

// src/components/board/BoardColumn.tsx
import { useState } from 'react'
import { useDroppable } from '@dnd-kit/core'
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
import { Board } from '../../types'
import { SortableCard } from './SortableCard'
import { useCreateCard, useDeleteBoard } from '../../hooks/useBoards'

type BoardColumnProps = {
board: Board
projectId: string
}

export function BoardColumn({ board, projectId }: BoardColumnProps) {
const [isAddingCard, setIsAddingCard] = useState(false)
const [cardTitle, setCardTitle] = useState('')

const { setNodeRef } = useDroppable({
id: board.id,
data: { type: 'board', board }
})

const createCard = useCreateCard()
const deleteBoard = useDeleteBoard()

const handleCardTitleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setCardTitle(e.target.value)
}

const handleStartAddingCard = () => {
setIsAddingCard(true)
}

const handleCancelAddCard = () => {
setIsAddingCard(false)
setCardTitle('')
}

const handleAddCard = (e: React.FormEvent) => {
e.preventDefault()
if (!cardTitle.trim()) return

createCard.mutate(
{
boardId: board.id,
title: cardTitle.trim()
},
{
onSuccess: () => {
setCardTitle('')
setIsAddingCard(false)
}
}
)
}

const handleDeleteBoard = () => {
if (window.confirm('このボードを削除しますか?')) {
deleteBoard.mutate({ projectId, boardId: board.id })
}
}

return (
<div
ref={setNodeRef}
className="w-72 shrink-0 bg-gray-100 rounded-lg flex flex-col max-h-full"
>
{/* ヘッダー */}
<div className="p-3 font-semibold flex items-center justify-between">
<span>{board.name}</span>
<div className="flex items-center gap-1">
<span className="text-sm text-gray-500">{board.cards.length}</span>
<button
onClick={handleDeleteBoard}
className="p-1 text-gray-400 hover:text-red-500"
>
×
</button>
</div>
</div>

{/* カードリスト */}
<div className="flex-1 overflow-y-auto px-3 pb-3 space-y-2">
<SortableContext
items={board.cards.map((c) => c.id)}
strategy={verticalListSortingStrategy}
>
{board.cards.map((card) => (
<SortableCard key={card.id} card={card} />
))}
</SortableContext>

{/* カード追加フォーム */}
{isAddingCard ? (
<form onSubmit={handleAddCard} className="space-y-2">
<textarea
value={cardTitle}
onChange={handleCardTitleChange}
placeholder="カードのタイトルを入力..."
autoFocus
rows={2}
className="w-full px-3 py-2 rounded border resize-none focus:outline-hidden focus:ring-2 focus:ring-blue-500"
/>
<div className="flex gap-2">
<button
type="submit"
disabled={createCard.isPending}
className="px-3 py-1.5 bg-blue-500 text-white rounded text-sm hover:bg-blue-600 disabled:opacity-50"
>
追加
</button>
<button
type="button"
onClick={handleCancelAddCard}
className="px-3 py-1.5 text-gray-600 hover:text-gray-800 text-sm"
>
キャンセル
</button>
</div>
</form>
) : (
<button
onClick={handleStartAddingCard}
className="w-full py-2 text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-200 rounded transition-colors"
>
+ カードを追加
</button>
)}
</div>
</div>
)
}

ソート可能なカード

// src/components/board/SortableCard.tsx
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { Card as CardType } from '../../types'
import { Card } from './Card'

type SortableCardProps = {
card: CardType
}

export function SortableCard({ card }: SortableCardProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging
} = useSortable({
id: card.id,
data: { type: 'card', card }
})

const style = {
transform: CSS.Transform.toString(transform),
transition
}

return (
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
<Card card={card} isDragging={isDragging} />
</div>
)
}
// src/components/board/Card.tsx
import { Card as CardType } from '../../types'
import clsx from 'clsx'

type CardProps = {
card: CardType
isDragging?: boolean
}

export function Card({ card, isDragging }: CardProps) {
return (
<div
className={clsx(
'bg-white p-3 rounded shadow-sm border border-gray-200 cursor-grab',
'hover:shadow-md transition-shadow',
isDragging && 'opacity-50 shadow-lg'
)}
>
<p className="text-sm">{card.title}</p>
{card.description && (
<p className="text-xs text-gray-500 mt-1 line-clamp-2">
{card.description}
</p>
)}
</div>
)
}

API フック

// src/hooks/useProjects.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import api from '../lib/api'
import { Project } from '../types'

export function useProjects() {
return useQuery<Project[]>({
queryKey: ['projects'],
queryFn: async () => {
const { data } = await api.get('/projects')
return data
}
})
}

export function useProject(id: string) {
return useQuery<Project>({
queryKey: ['projects', id],
queryFn: async () => {
const { data } = await api.get(`/projects/${id}`)
return data
}
})
}

export function useCreateProject() {
const queryClient = useQueryClient()

return useMutation({
mutationFn: async (data: { name: string; description?: string }) => {
const { data: project } = await api.post('/projects', data)
return project
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects'] })
}
})
}

export function useUpdateProject() {
const queryClient = useQueryClient()

return useMutation({
mutationFn: async ({
id,
data
}: {
id: string
data: { name?: string; description?: string }
}) => {
const { data: project } = await api.patch(`/projects/${id}`, data)
return project
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['projects', variables.id] })
queryClient.invalidateQueries({ queryKey: ['projects'] })
}
})
}

export function useDeleteProject() {
const queryClient = useQueryClient()

return useMutation({
mutationFn: async (id: string) => {
await api.delete(`/projects/${id}`)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects'] })
}
})
}
// src/hooks/useBoards.ts
import { useMutation, useQueryClient } from '@tanstack/react-query'
import api from '../lib/api'

export function useCreateBoard() {
const queryClient = useQueryClient()

return useMutation({
mutationFn: async ({ projectId, name }: { projectId: string; name: string }) => {
const { data } = await api.post(`/projects/${projectId}/boards`, { name })
return data
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['projects', variables.projectId] })
}
})
}

export function useDeleteBoard() {
const queryClient = useQueryClient()

return useMutation({
mutationFn: async ({
projectId,
boardId
}: {
projectId: string
boardId: string
}) => {
await api.delete(`/projects/${projectId}/boards/${boardId}`)
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['projects', variables.projectId] })
}
})
}

export function useCreateCard() {
const queryClient = useQueryClient()

return useMutation({
mutationFn: async ({
boardId,
title,
description
}: {
boardId: string
title: string
description?: string
}) => {
const { data } = await api.post(`/boards/${boardId}/cards`, {
title,
description
})
return data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects'] })
}
})
}

export function useUpdateCardPosition() {
const queryClient = useQueryClient()

return useMutation({
mutationFn: async ({
cardId,
boardId,
position
}: {
cardId: string
boardId: string
position: number
}) => {
const { data } = await api.patch(`/cards/${cardId}/position`, {
boardId,
position
})
return data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects'] })
}
})
}

起動と動作確認

環境変数の設定

フロントエンドが接続する API の URL を .env に設定します。

# project-app/.env
VITE_API_URL="http://localhost:3000/api"

28 章で app.setGlobalPrefix('api') を設定しているため、末尾の /api を忘れないでください(忘れるとすべての API リクエストが 404 になります)。

バックエンド

cd project-api
npm run start:dev

フロントエンド

cd project-app
npm run dev

動作確認

  1. http://localhost:5173 にアクセス
  2. ユーザー登録
  3. プロジェクトを作成
  4. ボードを追加
  5. カードを追加
  6. ドラッグ&ドロップで並び替え

発展課題

このプロジェクトをさらに拡張するアイデア:

機能説明
チームメンバー招待複数ユーザーでプロジェクト共有
カード詳細モーダル説明、期限、ラベル、添付ファイル
リアルタイム同期WebSocketで複数タブ/ユーザー間同期
検索・フィルターカード/プロジェクトの検索機能
アクティビティログ変更履歴の記録と表示
ダークモードテーマ切り替え

試してみよう

フルスタックプロジェクトが完成したら、以下の課題に挑戦してみましょう。

  1. カード詳細モーダルを実装する: カードをクリックしたときに詳細モーダルを表示し、説明文の編集、期限の設定、削除機能を追加してください。React Portalを使ってモーダルを実装します。

  2. 検索・フィルター機能を追加する: プロジェクト一覧ページとボード内で、キーワード検索ができるようにしてください。useDeferredValueを使って、入力中もタイピングが固まらないようにします(デバウンスとは異なり時間遅延ではなく、レンダリングの優先度を下げる仕組みです)。

  3. アクティビティログを実装する: カードの作成、移動、削除などの操作履歴を記録し、プロジェクト詳細ページで表示できるようにしてください。バックエンドにActivityモデルを追加します。

ヒント
  1. createPortalを使ってモーダルをdocument.bodyに描画します。モーダル外クリックで閉じる処理はonClickイベントの伝播を利用します。カード更新APIはPATCH /cards/:idで実装します。

  2. TanStack QueryのuseQueryに検索キーワードをqueryKeyに含めることで、キーワード変更時に自動的に再取得されます。useDeferredValueで遅延させた値をqueryKeyに渡すとUXが向上します。

  3. PrismaでActivityモデルを定義し、action(create/move/delete)、targetType(card/board)、targetIduserIdcreatedAtを持たせます。NestJSのインターセプターを使うと、各操作時に自動的にログを記録できます。

回答と解説

課題1: カード詳細モーダルを実装する

期限を扱うため、まず CarddueDate フィールドを追加します。Prisma スキーマと型定義の両方に追加し、マイグレーションを実行してください。

// prisma/schema.prisma(model Card に追加)
dueDate DateTime? @map("due_date")
npx prisma migrate dev --name add-card-due-date
// src/types/index.ts(Card 型に追加)
dueDate: string | null

これで CardModal から期限を読み書きできます。

// src/components/board/CardModal.tsx
import { createPortal } from 'react-dom'
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import api from '../../lib/api'
import { Card } from '../../types'

type CardModalProps = {
card: Card
onClose: () => void
}

export function CardModal({ card, onClose }: CardModalProps) {
const [title, setTitle] = useState(card.title)
const [description, setDescription] = useState(card.description || '')
const [dueDate, setDueDate] = useState(card.dueDate?.split('T')[0] || '')
const queryClient = useQueryClient()

const updateCard = useMutation({
mutationFn: async (data: Partial<Card>) => {
const { data: result } = await api.patch(`/cards/${card.id}`, data)
return result
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects'] })
onClose()
}
})

const deleteCard = useMutation({
mutationFn: async () => {
await api.delete(`/cards/${card.id}`)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects'] })
onClose()
}
})

const handleSave = () => {
updateCard.mutate({
title,
description: description || null,
dueDate: dueDate ? new Date(dueDate).toISOString() : null
})
}

const handleDelete = () => {
if (window.confirm('このカードを削除しますか?')) {
deleteCard.mutate()
}
}

// モーダル外クリックで閉じる
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose()
}
}

return createPortal(
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={handleBackdropClick}
>
<div className="bg-white rounded-lg shadow-xl w-full max-w-lg p-6">
<div className="flex justify-between items-start mb-4">
<h2 className="text-xl font-bold">カード詳細</h2>
<button onClick={onClose} className="text-gray-500 hover:text-gray-700">

</button>
</div>

<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">タイトル</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full px-3 py-2 border rounded"
/>
</div>

<div>
<label className="block text-sm font-medium mb-1">説明</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={4}
className="w-full px-3 py-2 border rounded"
/>
</div>

<div>
<label className="block text-sm font-medium mb-1">期限日</label>
<input
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
className="w-full px-3 py-2 border rounded"
/>
</div>
</div>

<div className="flex justify-between mt-6">
<button
onClick={handleDelete}
className="px-4 py-2 text-red-500 hover:text-red-700"
>
削除
</button>
<div className="space-x-2">
<button onClick={onClose} className="px-4 py-2 text-gray-600">
キャンセル
</button>
<button
onClick={handleSave}
disabled={updateCard.isPending}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
保存
</button>
</div>
</div>
</div>
</div>,
document.body
)
}

createPortalを使うと、コンポーネントツリーの外(document.body直下)にモーダルをレンダリングできます。これにより、親要素のoverflow: hiddenなどの影響を受けずにモーダルを表示できます。


課題2: 検索・フィルター機能を追加する

// src/hooks/useSearchCards.ts
import { useDeferredValue, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import api from '../lib/api'
import { Card } from '../types'

// 検索 API は所属ボードの名前も含めて返す想定
type CardSearchResult = Card & { board: { name: string } }

export function useSearchCards(projectId: string) {
const [searchTerm, setSearchTerm] = useState('')
const deferredSearchTerm = useDeferredValue(searchTerm)

const { data: searchResults, isLoading } = useQuery<CardSearchResult[] | null>({
queryKey: ['cards', 'search', projectId, deferredSearchTerm],
queryFn: async () => {
if (!deferredSearchTerm.trim()) return null
const { data } = await api.get(`/projects/${projectId}/cards/search`, {
params: { q: deferredSearchTerm }
})
return data
},
enabled: deferredSearchTerm.trim().length > 0
})

return {
searchTerm,
setSearchTerm,
searchResults,
isLoading,
isStale: searchTerm !== deferredSearchTerm
}
}
// src/components/board/SearchBar.tsx
import { useSearchCards } from '../../hooks/useSearchCards'

type SearchBarProps = {
projectId: string
}

export function SearchBar({ projectId }: SearchBarProps) {
const { searchTerm, setSearchTerm, searchResults, isLoading, isStale } =
useSearchCards(projectId)

return (
<div className="relative">
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="カードを検索..."
className="w-64 px-4 py-2 border rounded-lg"
/>
{isStale && (
<span className="absolute right-3 top-2.5 text-gray-400">...</span>
)}

{searchResults && searchResults.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-white border rounded-lg shadow-lg max-h-64 overflow-y-auto">
{searchResults.map((card) => (
<div key={card.id} className="p-3 hover:bg-gray-50 cursor-pointer">
<p className="font-medium">{card.title}</p>
<p className="text-sm text-gray-500">{card.board.name}</p>
</div>
))}
</div>
)}
</div>
)
}

useDeferredValueを使うと、検索キーワードの変更に対してUIの更新を遅延させ、入力中のカクつきを防げます。isStalesearchTerm !== deferredSearchTerm)で検索中かどうかを判定し、ローディングインジケータを表示しています。


課題3: アクティビティログを実装する

// prisma/schema.prisma(モデルを追加)
model Activity {
id String @id @default(uuid())
action String // create, update, move, delete
targetType String @map("target_type") // card, board, project
targetId String @map("target_id")
targetName String @map("target_name")
projectId String @map("project_id")
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
userId String @map("user_id")
user User @relation(fields: [userId], references: [id])
createdAt DateTime @default(now()) @map("created_at")

@@map("activities")
}

リレーションは両側に定義が必要です。Project モデルと User モデルにも以下を追加してください。

// model Project に追加
activities Activity[]

// model User に追加
activities Activity[]
// src/activities/activities.service.ts (NestJS)
import { Injectable } from '@nestjs/common'
import { PrismaService } from '../prisma/prisma.service'

@Injectable()
export class ActivitiesService {
constructor(private prisma: PrismaService) {}

async create(data: {
action: string
targetType: string
targetId: string
targetName: string
projectId: string
userId: string
}) {
return this.prisma.activity.create({ data })
}

async findByProject(projectId: string, limit = 20) {
return this.prisma.activity.findMany({
where: { projectId },
include: {
user: {
select: { id: true, name: true }
}
},
orderBy: { createdAt: 'desc' },
take: limit
})
}
}
// フロントエンド: src/components/ActivityLog.tsx
import { useQuery } from '@tanstack/react-query'
import api from '../lib/api'

type Activity = {
id: string
action: string
targetType: string
targetName: string
createdAt: string
user: { id: string; name: string }
}

type ActivityLogProps = {
projectId: string
}

export function ActivityLog({ projectId }: ActivityLogProps) {
const { data: activities } = useQuery<Activity[]>({
queryKey: ['activities', projectId],
queryFn: async () => {
const { data } = await api.get(`/projects/${projectId}/activities`)
return data
}
})

const getActionText = (action: string, targetType: string, targetName: string) => {
const actions: Record<string, string> = {
create: '作成しました',
update: '更新しました',
move: '移動しました',
delete: '削除しました'
}
const types: Record<string, string> = {
card: 'カード',
board: 'ボード'
}
return `${types[targetType]}${targetName}」を${actions[action]}`
}

return (
<div className="bg-white rounded-lg shadow p-4">
<h3 className="font-bold mb-4">アクティビティ</h3>
<ul className="space-y-3">
{activities?.map((activity) => (
<li key={activity.id} className="text-sm">
<span className="font-medium">{activity.user.name}</span>
<span className="text-gray-600 ml-1">
{getActionText(activity.action, activity.targetType, activity.targetName)}
</span>
<span className="text-gray-400 text-xs ml-2">
{new Date(activity.createdAt).toLocaleString('ja-JP')}
</span>
</li>
))}
</ul>
</div>
)
}

アクティビティログは、カードやボードの操作時にActivitiesService.createを呼び出して記録します。NestJSのインターセプターを使うと、各コントローラーでの明示的な呼び出しを減らせます。フロントエンドでは、プロジェクト詳細ページのサイドバーなどに表示するのが一般的です。

まとめ

この章では、フルスタックプロジェクトとしてカンバンボードアプリを構築しました。

  • @dnd-kitを使ったドラッグ&ドロップの実装方法
  • フロントエンドとバックエンドを統合したAPI連携パターン
  • TanStack Queryの楽観的更新によるUX改善
  • 実践的なコンポーネント分割とプロジェクト構造設計

本書のまとめ

備考

ここまで読んでいただきありがとうございました。本書で扱った内容は、モダンな Web 開発で広く使われている技術スタックです。技術は日々進化しているため、公式ドキュメントの更新を追いつつ、実際にアプリを作る中で理解を深めていってください。

本書を通じて、以下のモダンなフルスタック開発スキルを習得しました。

フロントエンド

  • React基礎: JSX、コンポーネント、Props、State
  • Hooks: useState、useEffect、useRef、useContext、useReducer、カスタムフック
  • TypeScript: 型安全なReact開発
  • スタイリング: Tailwind CSS、CSS設計
  • 状態管理: Zustand、Context API
  • データ取得: TanStack Query
  • ルーティング: React Router
  • 最適化: メモ化、パフォーマンス改善
  • 認証: JWT、セキュアなトークン管理

バックエンド

  • NestJS: モジュール構造、依存性注入、デコレーター
  • Prisma: 型安全なORM、マイグレーション
  • 認証: JWT、リフレッシュトークン、HttpOnly Cookie
  • API設計: RESTful、バリデーション

フルスタック統合

  • SPA認証: セキュアなトークン管理とリフレッシュ
  • API連携: Axiosインターセプター、エラーハンドリング
  • 実践プロジェクト: プロジェクト管理アプリの構築

これらの知識を基盤に、さらに実践的なアプリケーション開発に挑戦してください!

次に読む

読了です。次のステップとして以下を推奨します。