フルスタック実践プロジェクト
この章では、これまで学んだすべての知識を統合し、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.ts に import tailwindcss from '@tailwindcss/vite' を追加して plugins に tailwindcss() を登録します。さらに 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/boards、POST /boards/:id/cards、PATCH /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
動作確認
- http://localhost:5173 にアクセス
- ユーザー登録
- プロジェクトを作成
- ボードを追加
- カードを追加
- ドラッグ&ドロップで並び替え
発展課題
このプロジェクトをさらに拡張するアイデア:
| 機能 | 説明 |
|---|---|
| チームメンバー招待 | 複数ユーザーでプロジェクト共有 |
| カード詳細モーダル | 説明、期限、ラベル、添付ファイル |
| リアルタイム同期 | WebSocketで複数タブ/ユーザー間同期 |
| 検索・フィルター | カード/プロジェクトの検索機能 |
| アクティビティログ | 変更履歴の記録と表示 |
| ダークモード | テーマ切り替え |
試してみよう
フルスタックプロジェクトが完成したら、以下の課題に挑戦してみましょう。
-
カード詳細モーダルを実装する: カードをクリックしたときに詳細モーダルを表示し、説明文の編集、期限の設定、削除機能を追加してください。React Portalを使ってモーダルを実装します。
-
検索・フィルター機能を追加する: プロジェクト一覧ページとボード内で、キーワード検索ができるようにしてください。useDeferredValueを使って、入力中もタイピングが固まらないようにします(デバウンスとは異なり時間遅延ではなく、レンダリングの優先度を下げる仕組みです)。
-
アクティビティログを実装する: カードの作成、移動、削除などの操作履歴を記録し、プロジェクト詳細ページで表示できるようにしてください。バックエンドにActivityモデルを追加します。
ヒント
-
createPortalを使ってモーダルをdocument.bodyに描画します。モーダル外クリックで閉じる処理はonClickイベントの伝播を利用します。カード更新APIはPATCH /cards/:idで実装します。 -
TanStack Queryの
useQueryに検索キーワードをqueryKeyに含めることで、キーワード変更時に自動的に再取得されます。useDeferredValueで遅延させた値をqueryKeyに渡すとUXが向上します。 -
Prismaで
Activityモデルを定義し、action(create/move/delete)、targetType(card/board)、targetId、userId、createdAtを持たせます。NestJSのインターセプターを使うと、各操作時に自動的にログを記録できます。
回答と解説
課題1: カード詳細モーダルを実装する
期限を扱うため、まず Card に dueDate フィールドを追加します。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の更新を遅延させ、入力中のカクつきを防げます。isStale(searchTerm !== 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インターセプター、エラーハンドリング
- 実践プロジェクト: プロジェクト管理アプリの構築
これらの知識を基盤に、さらに実践的なアプリケーション開発に挑戦してください!
次に読む
読了です。次のステップとして以下を推奨します。
- TypeScript ガイド — TypeScript を言語仕様から実践まで体系的に学ぶ (全 21 章)
- Clean Code (TypeScript 版) — 設計規範のベストプラクティスを学ぶ
- Reinvent Notes Docs — 他のトピック (Markdown 記法 / Getting Started 等) を探す