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

実践プロジェクト

この章では、これまで学んだ知識を活かして、実践的なタスク管理アプリを構築します。

この章で学ぶこと

  • React + TypeScript + Tailwind CSSによる実践的なアプリ構築
  • Zustandでの状態管理とローカルストレージ永続化
  • React Routerでのルーティング実装
  • コンポーネント設計と機能分割のベストプラクティス
備考

この章では03章で作成したプロジェクトをベースに、タスク管理アプリを構築します。npm run devで開発サーバーを起動し、コードを書きながら学習を進めましょう。

備考

実際に手を動かしてアプリを構築すると、知識が定着しやすくなります。このプロジェクトでは本書で扱った React / TypeScript / Tailwind CSS / Zustand / React Router を一通り使います。コードを写しながら、自分の興味に応じてアレンジを加えてみてください。

プロジェクト概要

機能

  • タスクの追加・編集・削除
  • タスクの完了状態の切り替え
  • フィルタリング(すべて/未完了/完了)
  • ローカルストレージへの永続化

使用技術

  • React + TypeScript
  • Vite
  • Tailwind CSS
  • Zustand(状態管理)
  • React Router(ルーティング)

プロジェクト構成

src/
├── components/
│ ├── TaskForm.tsx
│ ├── TaskItem.tsx
│ ├── TaskList.tsx
│ └── FilterTabs.tsx
├── hooks/
│ └── useLocalStorage.ts
├── stores/
│ └── taskStore.ts
├── types/
│ └── task.ts
├── pages/
│ ├── Home.tsx
│ └── About.tsx
├── App.tsx
└── main.tsx

型定義

// src/types/task.ts
export type Task = {
id: string
title: string
completed: boolean
createdAt: Date
}

export type FilterType = 'all' | 'active' | 'completed'

Zustandストア

// src/stores/taskStore.ts
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { Task, FilterType } from '../types/task'

type TaskStore = {
tasks: Task[]
filter: FilterType
addTask: (title: string) => void
toggleTask: (id: string) => void
deleteTask: (id: string) => void
editTask: (id: string, title: string) => void
setFilter: (filter: FilterType) => void
clearCompleted: () => void
}

export const useTaskStore = create<TaskStore>()(
persist(
(set) => ({
tasks: [],
filter: 'all',

addTask: (title) =>
set((state) => ({
tasks: [
...state.tasks,
{
id: crypto.randomUUID(),
title,
completed: false,
createdAt: new Date()
}
]
})),

toggleTask: (id) =>
set((state) => ({
tasks: state.tasks.map((task) =>
task.id === id ? { ...task, completed: !task.completed } : task
)
})),

deleteTask: (id) =>
set((state) => ({
tasks: state.tasks.filter((task) => task.id !== id)
})),

editTask: (id, title) =>
set((state) => ({
tasks: state.tasks.map((task) =>
task.id === id ? { ...task, title } : task
)
})),

setFilter: (filter) => set({ filter }),

clearCompleted: () =>
set((state) => ({
tasks: state.tasks.filter((task) => !task.completed)
}))
}),
{
name: 'task-storage'
}
)
)

コンポーネント

TaskForm

// src/components/TaskForm.tsx
import { useState } from 'react'
import { useTaskStore } from '../stores/taskStore'

export function TaskForm() {
const [title, setTitle] = useState('')
const addTask = useTaskStore((state) => state.addTask)

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

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (title.trim()) {
addTask(title.trim())
setTitle('')
}
}

return (
<form onSubmit={handleSubmit} className="flex gap-2 mb-4">
<input
type="text"
value={title}
onChange={handleTitleChange}
placeholder="新しいタスクを入力..."
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg
focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="submit"
className="px-4 py-2 bg-blue-500 text-white rounded-lg
hover:bg-blue-600 transition-colors"
>
追加
</button>
</form>
)
}

TaskItem

// src/components/TaskItem.tsx
import { useState } from 'react'
import { useTaskStore } from '../stores/taskStore'
import type { Task } from '../types/task'

type TaskItemProps = {
task: Task
}

export function TaskItem({ task }: TaskItemProps) {
const [isEditing, setIsEditing] = useState(false)
const [editTitle, setEditTitle] = useState(task.title)

const toggleTask = useTaskStore((state) => state.toggleTask)
const deleteTask = useTaskStore((state) => state.deleteTask)
const editTask = useTaskStore((state) => state.editTask)

const handleToggle = () => {
toggleTask(task.id)
}

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

const handleStartEditing = () => {
setIsEditing(true)
}

const handleDelete = () => {
deleteTask(task.id)
}

const handleSave = () => {
if (editTitle.trim()) {
editTask(task.id, editTitle.trim())
setIsEditing(false)
}
}

const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') handleSave()
if (e.key === 'Escape') {
setEditTitle(task.title)
setIsEditing(false)
}
}

return (
<li className="flex items-center gap-3 p-3 bg-white rounded-lg shadow">
<input
type="checkbox"
checked={task.completed}
onChange={handleToggle}
className="w-5 h-5 accent-blue-500"
/>

{isEditing ? (
<input
type="text"
value={editTitle}
onChange={handleEditTitleChange}
onBlur={handleSave}
onKeyDown={handleKeyDown}
autoFocus
className="flex-1 px-2 py-1 border rounded"
/>
) : (
<span
onDoubleClick={handleStartEditing}
className={`flex-1 ${
task.completed ? 'line-through text-gray-400' : ''
}`}
>
{task.title}
</span>
)}

<button
onClick={handleDelete}
className="px-2 py-1 text-red-500 hover:text-red-700"
>
削除
</button>
</li>
)
}

FilterTabs

// src/components/FilterTabs.tsx
import { useTaskStore } from '../stores/taskStore'
import type { FilterType } from '../types/task'

const filters: { value: FilterType; label: string }[] = [
{ value: 'all', label: 'すべて' },
{ value: 'active', label: '未完了' },
{ value: 'completed', label: '完了' }
]

export function FilterTabs() {
const filter = useTaskStore((state) => state.filter)
const setFilter = useTaskStore((state) => state.setFilter)

const createFilterHandler = (value: FilterType) => () => {
setFilter(value)
}

return (
<div className="flex gap-2 mb-4">
{filters.map(({ value, label }) => (
<button
key={value}
onClick={createFilterHandler(value)}
className={`px-4 py-2 rounded-lg transition-colors ${
filter === value
? 'bg-blue-500 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
{label}
</button>
))}
</div>
)
}

TaskList

// src/components/TaskList.tsx
import { useTaskStore } from '../stores/taskStore'
import { TaskItem } from './TaskItem'

export function TaskList() {
const tasks = useTaskStore((state) => state.tasks)
const filter = useTaskStore((state) => state.filter)

const filteredTasks = tasks.filter((task) => {
if (filter === 'active') return !task.completed
if (filter === 'completed') return task.completed
return true
})

if (filteredTasks.length === 0) {
return (
<p className="text-center text-gray-500 py-8">
タスクがありません
</p>
)
}

return (
<ul className="space-y-2">
{filteredTasks.map((task) => (
<TaskItem key={task.id} task={task} />
))}
</ul>
)
}

ページ

// src/pages/Home.tsx
import { TaskForm } from '../components/TaskForm'
import { FilterTabs } from '../components/FilterTabs'
import { TaskList } from '../components/TaskList'
import { useTaskStore } from '../stores/taskStore'

export function Home() {
const tasks = useTaskStore((state) => state.tasks)
const clearCompleted = useTaskStore((state) => state.clearCompleted)

const completedCount = tasks.filter((t) => t.completed).length
const activeCount = tasks.length - completedCount

const handleClearCompleted = () => {
clearCompleted()
}

return (
<div className="max-w-lg mx-auto p-4">
<h1 className="text-2xl font-bold mb-6">タスク管理</h1>

<TaskForm />
<FilterTabs />
<TaskList />

<div className="mt-4 flex justify-between text-sm text-gray-500">
<span>残り: {activeCount}</span>
{completedCount > 0 && (
<button
onClick={handleClearCompleted}
className="hover:text-red-500"
>
完了済みを削除
</button>
)}
</div>
</div>
)
}
// src/pages/About.tsx
export function About() {
return (
<div className="max-w-lg mx-auto p-4">
<h1 className="text-2xl font-bold mb-6">About</h1>
<p>React + TypeScript + Zustand + React Router で作るタスク管理アプリです。</p>
</div>
)
}

アプリケーション

// src/App.tsx
import { Routes, Route, Link } from 'react-router'
import { Home } from './pages/Home'
import { About } from './pages/About'

function App() {
return (
<div className="min-h-screen bg-gray-100">
<nav className="bg-white shadow p-4 mb-4">
<div className="max-w-lg mx-auto flex gap-4">
<Link to="/" className="text-blue-500 hover:underline">
ホーム
</Link>
<Link to="/about" className="text-blue-500 hover:underline">
About
</Link>
</div>
</nav>

<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
</Routes>
</div>
)
}

export default App
// src/main.tsx(21章の宣言的ルーティング構成に戻す)
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router'
import App from './App'
import './index.css'

createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>
)

発展課題

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

  1. 期限の追加: タスクに期限を設定し、期限切れを警告
  2. カテゴリ/タグ: タスクを分類して管理
  3. ドラッグ&ドロップ: タスクの並び替え
  4. ダークモード: テーマ切り替え機能
  5. API連携: バックエンドと連携してデータ永続化
  6. テスト: Vitestでユニットテストを追加

試してみよう

タスク管理アプリが完成したら、以下の発展課題に取り組んでみてください。

  1. 期限機能を追加する: タスクに期限日を設定できるようにし、期限切れのタスクを赤く表示してください。Date型のフィールドを追加し、フォームにdate入力を追加します。

  2. カテゴリ/タグ機能を追加する: タスクにカテゴリを設定し、カテゴリでフィルタリングできるようにしてください。Zustandストアにカテゴリ配列を追加します。

  3. ダークモードを実装する: Tailwind CSSのdark:クラスを使用して、ダークモードに対応させてください。テーマ状態もZustandで管理し、永続化しましょう。

ヒント
  1. Task型にdueDate?: Dateを追加し、TaskFormにdate inputを追加します。期限切れ判定はnew Date() > new Date(task.dueDate)で行います(persistで復元した値は文字列になるためnew Date()で変換します)。
  2. FilterTypeを拡張するか、別のcategoryFilter状態を追加します。UIはFilterTabsを参考に実装できます。
  3. Tailwind v4のdark:はデフォルトでOS設定(prefers-color-scheme)に連動します。class切り替え式にするにはCSSで@custom-variant darkを定義した上で、html要素にclass="dark"を付けます。localStorageでテーマを永続化しましょう。
回答と解説

課題1: 期限機能を追加する

// src/types/task.ts
export type Task = {
id: string
title: string
completed: boolean
createdAt: Date
dueDate?: Date // 期限日を追加
}
// src/stores/taskStore.ts(TaskStore型とaddTaskを修正)
type TaskStore = {
// ...
addTask: (title: string, dueDate?: Date) => void
// ...
}

addTask: (title, dueDate) =>
set((state) => ({
tasks: [
...state.tasks,
{
id: crypto.randomUUID(),
title,
completed: false,
createdAt: new Date(),
dueDate
}
]
})),
// src/components/TaskForm.tsx
import { useState } from 'react'
import { useTaskStore } from '../stores/taskStore'

export function TaskForm() {
const [title, setTitle] = useState('')
const [dueDate, setDueDate] = useState('')
const addTask = useTaskStore((state) => state.addTask)

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (title.trim()) {
addTask(title.trim(), dueDate ? new Date(dueDate) : undefined)
setTitle('')
setDueDate('')
}
}

return (
<form onSubmit={handleSubmit} className="flex gap-2 mb-4">
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="新しいタスクを入力..."
className="flex-1 px-4 py-2 border rounded-lg"
/>
<input
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
className="px-4 py-2 border rounded-lg"
/>
<button type="submit" className="px-4 py-2 bg-blue-500 text-white rounded-lg">
追加
</button>
</form>
)
}
// src/components/TaskItem.tsx(期限切れ表示を追加)
const isOverdue = task.dueDate && new Date() > new Date(task.dueDate) && !task.completed

return (
<li className={`flex items-center gap-3 p-3 rounded-lg shadow ${isOverdue ? 'bg-red-50 border-red-200' : 'bg-white'}`}>
{/* ... */}
{task.dueDate && (
<span className={`text-sm ${isOverdue ? 'text-red-500 font-bold' : 'text-gray-500'}`}>
{new Date(task.dueDate).toLocaleDateString()}
</span>
)}
</li>
)

期限日を過ぎたタスクは背景色を赤くし、日付を赤字で表示します。完了済みのタスクは期限切れ表示をしないようにしています。なお、persistはJSONで保存するため、Dateはリロード後に文字列として復元されます。使う場所でnew Date()に戻すか、最初からISO文字列(string型)で持つ設計にします。


課題2: カテゴリ/タグ機能を追加する

// src/types/task.ts
export type Task = {
id: string
title: string
completed: boolean
createdAt: Date
category?: string // カテゴリを追加
}

export type FilterType = 'all' | 'active' | 'completed'
// src/stores/taskStore.ts
type TaskStore = {
tasks: Task[]
filter: FilterType
categoryFilter: string | null // カテゴリフィルターを追加
categories: string[] // カテゴリ一覧
addTask: (title: string, category?: string) => void
setCategoryFilter: (category: string | null) => void
// ...
}

export const useTaskStore = create<TaskStore>()(
persist(
(set) => ({
tasks: [],
filter: 'all',
categoryFilter: null,
categories: ['仕事', '個人', '買い物'],

addTask: (title, category) =>
set((state) => ({
tasks: [
...state.tasks,
{
id: crypto.randomUUID(),
title,
completed: false,
createdAt: new Date(),
category
}
]
})),

setCategoryFilter: (category) => set({ categoryFilter: category }),
// ...
}),
{ name: 'task-storage' }
)
)
// src/components/CategoryTabs.tsx
import { useTaskStore } from '../stores/taskStore'

export function CategoryTabs() {
const categories = useTaskStore((state) => state.categories)
const categoryFilter = useTaskStore((state) => state.categoryFilter)
const setCategoryFilter = useTaskStore((state) => state.setCategoryFilter)

const handleClearFilter = () => {
setCategoryFilter(null)
}

const createCategoryHandler = (category: string) => () => {
setCategoryFilter(category)
}

return (
<div className="flex gap-2 mb-4 flex-wrap">
<button
onClick={handleClearFilter}
className={`px-3 py-1 rounded ${
categoryFilter === null ? 'bg-blue-500 text-white' : 'bg-gray-200'
}`}
>
すべて
</button>
{categories.map((category) => (
<button
key={category}
onClick={createCategoryHandler(category)}
className={`px-3 py-1 rounded ${
categoryFilter === category ? 'bg-blue-500 text-white' : 'bg-gray-200'
}`}
>
{category}
</button>
))}
</div>
)
}

課題3: ダークモードを実装する

// src/stores/themeStore.ts
import { create } from 'zustand'
import { persist } from 'zustand/middleware'

type ThemeStore = {
isDark: boolean
toggleTheme: () => void
}

export const useThemeStore = create<ThemeStore>()(
persist(
(set) => ({
isDark: false,
toggleTheme: () => set((state) => ({ isDark: !state.isDark }))
}),
{ name: 'theme-storage' }
)
)
// src/App.tsx
import { useEffect } from 'react'
import { useThemeStore } from './stores/themeStore'

function App() {
const isDark = useThemeStore((state) => state.isDark)
const toggleTheme = useThemeStore((state) => state.toggleTheme)

useEffect(() => {
document.documentElement.classList.toggle('dark', isDark)
}, [isDark])

return (
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 transition-colors">
<nav className="bg-white dark:bg-gray-800 shadow p-4 mb-4">
<div className="max-w-lg mx-auto flex justify-between items-center">
<span className="font-bold dark:text-white">タスク管理</span>
<button
onClick={toggleTheme}
className="p-2 rounded bg-gray-200 dark:bg-gray-700 dark:text-white"
>
{isDark ? '🌞' : '🌙'}
</button>
</div>
</nav>
{/* ... */}
</div>
)
}
/* src/index.css(class切り替え式にdarkバリアントを上書き) */
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));

@custom-variantdarkバリアントをclass連動に上書きすると、html要素にclass="dark"が付いているときにdark:のスタイルが適用されます。useEffectisDarkの変更を監視し、document.documentElementのクラスを切り替えます。persistミドルウェアでテーマ設定も永続化されます。

まとめ

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

  • React基礎: コンポーネント、Props、State、イベント
  • フック: useState、useEffect、カスタムフック
  • TypeScript: 型安全な開発
  • Tailwind CSS: ユーティリティファーストなスタイリング
  • Zustand: シンプルな状態管理
  • TanStack Query: サーバー状態管理
  • React Router: クライアントサイドルーティング

これらの知識を基盤に、次の章ではさらにフルスタック開発へステップアップします。

次の章では、SPA認証の実装方法を学びます。

次に読む

フルスタック開発 へ進みます。SPA 認証、NestJS + Prisma バックエンド、フルスタックプロジェクトでフロント + バックを統合します。