これまで学んできた知識を総動員して、本格的なTodoアプリケーションを作成します。
ローカルストレージでのデータ永続化、フィルタリング、検索機能など、実用的な機能を実装しながら、実践的なReact開発の流れを体験しましょう。
この記事で学べること
- コンポーネント設計のベストプラクティス
- カスタムフックを使ったロジックの分離
- TypeScriptによる型安全な開発
- ローカルストレージを使ったデータ永続化
- 検索・フィルタリング・ソート機能の実装
Contents
1. プロジェクトのセットアップ
まずは Vite を使って React + TypeScript のプロジェクトを作成します。
Vite は webpack より起動が速く、開発体験に優れているため、現在のReact開発では標準的な選択肢になっています。
プロジェクト作成
npm create vite@latest todo-app -- --template react-ts
cd todo-app
npm install
npm run devJavaScript版を使う場合--template react-ts の代わりに --template react を指定すると、TypeScriptなしのプロジェクトが作れます。
この記事ではTS/JSどちらのコードも紹介します。
TSで書ける方を推奨しますので、この辺りからは、TSを中心に解説し補足説明としてJS版の記入例を紹介します。
ディレクトリ構成
機能ごとにファイルを分割することで、コードの見通しがよくなり、チーム開発やメンテナンスが楽になります。
src/
├── components/ # UIコンポーネント
│ ├── TodoForm.tsx # タスク入力フォーム
│ ├── TodoItem.tsx # 個々のTodoアイテム
│ ├── TodoList.tsx # Todoの一覧表示
│ └── TodoFilters.tsx # フィルタ・検索・ソート
├── hooks/
│ └── useTodos.ts # Todoに関するロジックをまとめたカスタムフック
├── types/
│ └── todo.ts # 型定義(TypeScriptのみ)
├── utils/
│ └── storage.ts # ローカルストレージの操作
├── App.tsx
├── App.css
└── main.tsx2. 型定義
TypeScriptを使う場合、最初に「どんなデータを扱うか」を型として定義します。
型を先に決めておくことで、コード補完が効くようになり、ミスを事前に防ぎやすくなります。
// types/todo.ts
export interface Todo {
id: string
text: string
completed: boolean
createdAt: Date
priority: 'low' | 'medium' | 'high'
category?: string
}
export type FilterType = 'all' | 'active' | 'completed'
export type SortType = 'createdAt' | 'priority' | 'text'ポイント:ユニオン型の使い方
'low' | 'medium' | 'high' のように書くことで、この3つ以外の値は型エラーになります。
「文字列ならなんでもOK」な string 型より安全に書けます。
3. ローカルストレージユーティリティ
ページをリロードしてもデータが消えないよう、ブラウザの localStorage にTodoを保存します。
この処理をユーティリティ関数にまとめることで、コンポーネントからは「保存・読み込み」という操作だけを意識すればよくなります。
// utils/storage.ts
import { type Todo } from '../types/todo'
const STORAGE_KEY = 'todos'
export const storage = {
getTodos: (): Todo[] => {
try {
const data = localStorage.getItem(STORAGE_KEY)
if (!data) return []
// JSON.parse後はcreatedAtが”文字列"のままなので、配列に適切な型を定義
type RawTodo = Omit<Todo, 'createdAt'> & { createdAt: string }
const todos: RawTodo[] = JSON.parse(data)
// createdAtだけDateオブジェクトに変換して返す
return todos.map((todo) => ({
...todo,
createdAt: new Date(todo.createdAt)
}))
} catch (error) {
console.error('Error loading todos:', error)
return []
}
},
saveTodos: (todos: Todo[]): void => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos))
} catch (error) {
console.error('Error saving todos:', error)
}
},
clearTodos: (): void => {
localStorage.removeItem(STORAGE_KEY)
}
}JavaScript版
const STORAGE_KEY = 'todos'
export const storage = {
getTodos: () => {
try {
const data = localStorage.getItem(STORAGE_KEY)
if (!data) return []
const todos = JSON.parse(data)
return todos.map(todo => ({
...todo,
createdAt: new Date(todo.createdAt)
}))
} catch (error) {
console.error('Error loading todos:', error)
return []
}
},
saveTodos: (todos) => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos))
} catch (error) {
console.error('Error saving todos:', error)
}
},
clearTodos: () => {
localStorage.removeItem(STORAGE_KEY)
}
}なぜ try-catch で囲むの?localStorage はブラウザによっては容量制限(一般的に5MB程度)があり、プライベートモードでは使えないこともあります。エラー時にアプリが止まらないよう、必ず例外処理を入れましょう。
なぜ createdAt だけ変換が必要なの?JSON.stringify で保存すると Date オブジェクトは文字列("2024-01-01T00:00:00.000Z")になります。JSON.parse では自動的にDateに戻らないため、new Date(...) で手動変換が必要です。
4. カスタムフック
Todoアプリの状態管理とビジネスロジックをカスタムフック useTodos にまとめます。
カスタムフックとは、use で始まる関数で、複数のReactフックをひとつにまとめたものです。
コンポーネントから切り離すことで、テストや再利用がしやすくなります。
// hooks/useTodos.ts
import { useState, useEffect } from 'react'
import { type Todo, type FilterType, type SortType } from '../types/todo'
import { storage } from '../utils/storage'
export function useTodos() {
// useState の遅延初期化でローカルストレージから読み込む
// () => ... の形で関数を渡すと、初回レンダリング時だけ実行される
// useEffect + setState を使わないため、カスケードレンダリングが起きない
const [todos, setTodos] = useState<Todo[]>(() => storage.getTodos())
const [filter, setFilter] = useState<FilterType>('all')
const [searchTerm, setSearchTerm] = useState<string>('')
const [sortBy, setSortBy] = useState<SortType>('createdAt')
// todosが変わるたびにローカルストレージへ保存する
// 初期値がすでに正しく設定されているため、初回スキップの条件も不要になる
useEffect(() => {
storage.saveTodos(todos)
}, [todos])
// ── CRUD操作 ──────────────────────────────────────
// Todo追加
const addTodo = (
text: string,
priority: Todo['priority'] = 'medium',
category?: string
): void => {
const newTodo: Todo = {
id: crypto.randomUUID(), // ブラウザ標準のUUID生成
text,
completed: false,
createdAt: new Date(),
priority,
category
}
// 新しいTodoをリストの先頭に追加
setTodos([newTodo, ...todos])
}
// Todo削除
const deleteTodo = (id: string): void => {
setTodos(todos.filter(todo => todo.id !== id))
}
// Todo更新(テキストや優先度など部分的に変更できる)
const updateTodo = (id: string, updates: Partial<Todo>): void => {
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, ...updates } : todo
))
}
// 完了状態のトグル
const toggleTodo = (id: string): void => {
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
))
}
// 完了済みのTodoをまとめて削除
const clearCompleted = (): void => {
setTodos(todos.filter(todo => !todo.completed))
}
// 全件の完了/未完了を一括切り替え
const toggleAll = (): void => {
const allCompleted = todos.every(todo => todo.completed)
setTodos(todos.map(todo => ({ ...todo, completed: !allCompleted })))
}
// ── フィルタリング・ソート ──────────────────────────
const getFilteredTodos = (): Todo[] => {
let filtered = todos
// ステータスでフィルター(全件 / 未完了 / 完了)
if (filter === 'active') {
filtered = filtered.filter(todo => !todo.completed)
} else if (filter === 'completed') {
filtered = filtered.filter(todo => todo.completed)
}
// テキストまたはカテゴリで検索
if (searchTerm) {
filtered = filtered.filter(todo =>
todo.text.toLowerCase().includes(searchTerm.toLowerCase()) ||
todo.category?.toLowerCase().includes(searchTerm.toLowerCase())
)
}
// ソート(元の配列を変えないよう [...filtered] でコピーしてからsort)
const sorted = [...filtered].sort((a, b) => {
if (sortBy === 'createdAt') {
return b.createdAt.getTime() - a.createdAt.getTime() // 新しい順
} else if (sortBy === 'priority') {
const priorityOrder = { high: 3, medium: 2, low: 1 }
return priorityOrder[b.priority] - priorityOrder[a.priority] // 優先度高い順
} else {
return a.text.localeCompare(b.text) // 名前のあいうえお順
}
})
return sorted
}
const filteredTodos = getFilteredTodos()
// 統計情報(フィルター前の全件で計算)
const stats = {
total: todos.length,
active: todos.filter(t => !t.completed).length,
completed: todos.filter(t => t.completed).length
}
// コンポーネントが必要なものだけを返す
return {
todos: filteredTodos,
stats,
filter,
setFilter,
searchTerm,
setSearchTerm,
sortBy,
setSortBy,
addTodo,
deleteTodo,
updateTodo,
toggleTodo,
clearCompleted,
toggleAll
}
}JavaScript版
import { useState, useEffect } from 'react'
import { storage } from '../utils/storage'
export function useTodos() {
const [todos, setTodos] = useState(() => storage.getTodos())
const [filter, setFilter] = useState('all')
const [searchTerm, setSearchTerm] = useState('')
const [sortBy, setSortBy] = useState('createdAt')
useEffect(() => {
storage.saveTodos(todos)
}, [todos])
const addTodo = (text, priority = 'medium', category) => {
const newTodo = {
id: crypto.randomUUID(),
text,
completed: false,
createdAt: new Date(),
priority,
category
}
setTodos([newTodo, ...todos])
}
const deleteTodo = (id) => setTodos(todos.filter(todo => todo.id !== id))
const updateTodo = (id, updates) => {
setTodos(todos.map(todo => todo.id === id ? { ...todo, ...updates } : todo))
}
const toggleTodo = (id) => {
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
))
}
const clearCompleted = () => setTodos(todos.filter(todo => !todo.completed))
const toggleAll = () => {
const allCompleted = todos.every(todo => todo.completed)
setTodos(todos.map(todo => ({ ...todo, completed: !allCompleted })))
}
const getFilteredTodos = () => {
let filtered = todos
if (filter === 'active') filtered = filtered.filter(t => !t.completed)
else if (filter === 'completed') filtered = filtered.filter(t => t.completed)
if (searchTerm) {
filtered = filtered.filter(t =>
t.text.toLowerCase().includes(searchTerm.toLowerCase()) ||
t.category?.toLowerCase().includes(searchTerm.toLowerCase())
)
}
return [...filtered].sort((a, b) => {
if (sortBy === 'createdAt') return b.createdAt - a.createdAt
if (sortBy === 'priority') {
const order = { high: 3, medium: 2, low: 1 }
return order[b.priority] - order[a.priority]
}
return a.text.localeCompare(b.text)
})
}
const stats = {
total: todos.length,
active: todos.filter(t => !t.completed).length,
completed: todos.filter(t => t.completed).length
}
return {
todos: getFilteredTodos(),
stats,
filter, setFilter,
searchTerm, setSearchTerm,
sortBy, setSortBy,
addTodo, deleteTodo, updateTodo, toggleTodo, clearCompleted, toggleAll
}
}カスタムフックの設計ポイント
- フック内では「状態とロジック」だけを扱い、JSXは書かない
- 外部に公開するものだけを
returnで返す(内部関数は隠蔽できる) - これにより、コンポーネントは「表示」に専念できる
5. コンポーネント実装
TodoForm:タスク入力フォーム
タスクのテキスト・優先度・カテゴリを入力するフォームです。
送信処理は onAdd として親から受け取り、フォーム自体はUIの役割に徹します(ロジックはカスタムフックに委ねる設計)。
// components/TodoForm.tsx
import { useState } from 'react'
import { type Todo } from '../types/todo'
interface TodoFormProps {
onAdd: (text: string, priority: Todo['priority'], category?: string) => void
}
export function TodoForm({ onAdd }: TodoFormProps) {
const [text, setText] = useState('')
const [priority, setPriority] = useState<Todo['priority']>('medium')
const [category, setCategory] = useState('')
const handleSubmit = (e: React.SubmitEvent) => {
e.preventDefault()
if (!text.trim()) return // 空文字は無視
onAdd(text.trim(), priority, category.trim() || undefined)
// 送信後にフォームをリセット
setText('')
setCategory('')
setPriority('medium')
}
return (
<form onSubmit={handleSubmit} className="todo-form">
<div className="form-row">
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="新しいタスクを入力..."
className="todo-input"
/>
<select
value={priority}
onChange={(e) => setPriority(e.target.value as Todo['priority'])}
className="priority-select"
>
<option value="low">低</option>
<option value="medium">中</option>
<option value="high">高</option>
</select>
<input
type="text"
value={category}
onChange={(e) => setCategory(e.target.value)}
placeholder="カテゴリ(任意)"
className="category-input"
/>
<button type="submit" className="add-button">追加</button>
</div>
</form>
)
}JavaScript版
import { useState } from 'react'
export function TodoForm({ onAdd }) {
const [text, setText] = useState('')
const [priority, setPriority] = useState('medium')
const [category, setCategory] = useState('')
const handleSubmit = (e) => {
e.preventDefault()
if (!text.trim()) return
onAdd(text.trim(), priority, category.trim() || undefined)
setText('')
setCategory('')
setPriority('medium')
}
return (
<form onSubmit={handleSubmit} className="todo-form">
<div className="form-row">
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="新しいタスクを入力..."
className="todo-input"
/>
<select
value={priority}
onChange={(e) => setPriority(e.target.value)}
className="priority-select"
>
<option value="low">低</option>
<option value="medium">中</option>
<option value="high">高</option>
</select>
<input
type="text"
value={category}
onChange={(e) => setCategory(e.target.value)}
placeholder="カテゴリ(任意)"
className="category-input"
/>
<button type="submit" className="add-button">追加</button>
</div>
</form>
)
}TodoItem:個々のTodoアイテム
チェック・編集・削除ができる1件分のTodoカードです。
「ダブルクリックで編集モードに切り替わる」というUXを実装しています。
// components/TodoItem.tsx
import { useState } from 'react'
import { type Todo } from '../types/todo'
interface TodoItemProps {
todo: Todo
onToggle: (id: string) => void
onDelete: (id: string) => void
onUpdate: (id: string, updates: Partial<Todo>) => void
}
export function TodoItem({ todo, onToggle, onDelete, onUpdate }: TodoItemProps) {
const [isEditing, setIsEditing] = useState(false)
const [editText, setEditText] = useState(todo.text)
const handleSave = () => {
if (editText.trim()) {
onUpdate(todo.id, { text: editText.trim() })
setIsEditing(false)
}
}
const handleCancel = () => {
setEditText(todo.text) // 変更を破棄して元のテキストに戻す
setIsEditing(false)
}
// キーボード操作(Enter: 保存、Escape: キャンセル)
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') handleSave()
else if (e.key === 'Escape') handleCancel()
}
return (
<div className={`todo-item ${todo.completed ? 'completed' : ''} priority-${todo.priority}`}>
<div className="todo-content">
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
className="todo-checkbox"
/>
{isEditing ? (
// 編集モード:テキスト入力フィールドを表示
<input
type="text"
value={editText}
onChange={(e) => setEditText(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleSave} // フォーカスを外したときも保存
className="todo-edit-input"
autoFocus
/>
) : (
// 表示モード:ダブルクリックで編集モードに切り替え
<div className="todo-text-wrapper">
<span className="todo-text" onDoubleClick={() => setIsEditing(true)}>
{todo.text}
</span>
{todo.category && (
<span className="todo-category">{todo.category}</span>
)}
<span className="todo-date">
{todo.createdAt.toLocaleDateString('ja-JP')}
</span>
</div>
)}
</div>
<div className="todo-actions">
{isEditing ? (
<>
<button onClick={handleSave} className="btn-save">保存</button>
<button onClick={handleCancel} className="btn-cancel">キャンセル</button>
</>
) : (
<>
<button onClick={() => setIsEditing(true)} className="btn-edit">編集</button>
<button onClick={() => onDelete(todo.id)} className="btn-delete">削除</button>
</>
)}
</div>
</div>
)
}JavaScript版
import { useState } from 'react'
export function TodoItem({ todo, onToggle, onDelete, onUpdate }) {
const [isEditing, setIsEditing] = useState(false)
const [editText, setEditText] = useState(todo.text)
const handleSave = () => {
if (editText.trim()) {
onUpdate(todo.id, { text: editText.trim() })
setIsEditing(false)
}
}
const handleCancel = () => {
setEditText(todo.text)
setIsEditing(false)
}
const handleKeyDown = (e) => {
if (e.key === 'Enter') handleSave()
else if (e.key === 'Escape') handleCancel()
}
return (
<div className={`todo-item ${todo.completed ? 'completed' : ''} priority-${todo.priority}`}>
<div className="todo-content">
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
className="todo-checkbox"
/>
{isEditing ? (
<input
type="text"
value={editText}
onChange={(e) => setEditText(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleSave}
className="todo-edit-input"
autoFocus
/>
) : (
<div className="todo-text-wrapper">
<span className="todo-text" onDoubleClick={() => setIsEditing(true)}>
{todo.text}
</span>
{todo.category && <span className="todo-category">{todo.category}</span>}
<span className="todo-date">
{todo.createdAt.toLocaleDateString('ja-JP')}
</span>
</div>
)}
</div>
<div className="todo-actions">
{isEditing ? (
<>
<button onClick={handleSave} className="btn-save">保存</button>
<button onClick={handleCancel} className="btn-cancel">キャンセル</button>
</>
) : (
<>
<button onClick={() => setIsEditing(true)} className="btn-edit">編集</button>
<button onClick={() => onDelete(todo.id)} className="btn-delete">削除</button>
</>
)}
</div>
</div>
)
}ハマりやすいポイント:onBlur での保存onBlur={handleSave} を設定すると、入力フィールドからフォーカスが外れたとき(他の要素をクリックしたときなど)に自動保存されます。
ただし「キャンセル」ボタンをクリックした場合も onBlur が先に発火するため、より厳密な制御が必要な場合は onBlur を外してボタン操作のみで保存・キャンセルするのも一つの方法です。
TodoFilters:フィルタ・検索・ソート
検索ボックス、フィルターボタン、ソートセレクトをまとめたコンポーネントです。
状態は親(useTodos)が持ち、このコンポーネントはUIと操作のトリガーに徹します。
components/TodoFilters.tsx:
// components/TodoFilters.tsx
import { type FilterType, type SortType } from '../types/todo'
interface TodoFiltersProps {
filter: FilterType
setFilter: (filter: FilterType) => void
searchTerm: string
setSearchTerm: (term: string) => void
sortBy: SortType
setSortBy: (sort: SortType) => void
stats: { total: number; active: number; completed: number }
onClearCompleted: () => void
}
export function TodoFilters({
filter, setFilter,
searchTerm, setSearchTerm,
sortBy, setSortBy,
stats, onClearCompleted
}: TodoFiltersProps) {
return (
<div className="todo-filters">
{/* 検索ボックス */}
<div className="search-box">
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="検索..."
className="search-input"
/>
</div>
{/* フィルターボタン */}
<div className="filter-buttons">
<button onClick={() => setFilter('all')} className={filter === 'all' ? 'active' : ''}>
すべて ({stats.total})
</button>
<button onClick={() => setFilter('active')} className={filter === 'active' ? 'active' : ''}>
未完了 ({stats.active})
</button>
<button onClick={() => setFilter('completed')} className={filter === 'completed' ? 'active' : ''}>
完了 ({stats.completed})
</button>
</div>
{/* ソート */}
<div className="sort-box">
<label>並び替え:</label>
<select value={sortBy} onChange={(e) => setSortBy(e.target.value as SortType)}>
<option value="createdAt">作成日時</option>
<option value="priority">優先度</option>
<option value="text">名前</option>
</select>
</div>
{/* 完了済みが1件以上あるときだけ表示 */}
{stats.completed > 0 && (
<button onClick={onClearCompleted} className="btn-clear">
完了済みを削除
</button>
)}
</div>
)
}JavaScript版
export function TodoFilters({
filter, setFilter,
searchTerm, setSearchTerm,
sortBy, setSortBy,
stats, onClearCompleted
}) {
return (
<div className="todo-filters">
<div className="search-box">
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="検索..."
className="search-input"
/>
</div>
<div className="filter-buttons">
<button onClick={() => setFilter('all')} className={filter === 'all' ? 'active' : ''}>
すべて ({stats.total})
</button>
<button onClick={() => setFilter('active')} className={filter === 'active' ? 'active' : ''}>
未完了 ({stats.active})
</button>
<button onClick={() => setFilter('completed')} className={filter === 'completed' ? 'active' : ''}>
完了 ({stats.completed})
</button>
</div>
<div className="sort-box">
<label>並び替え:</label>
<select value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
<option value="createdAt">作成日時</option>
<option value="priority">優先度</option>
<option value="text">名前</option>
</select>
</div>
{stats.completed > 0 && (
<button onClick={onClearCompleted} className="btn-clear">完了済みを削除</button>
)}
</div>
)
}TodoList:一覧表示
components/TodoList.tsx:
// components/TodoList.tsx
import { type Todo } from '../types/todo'
import { TodoItem } from './TodoItem'
interface TodoListProps {
todos: Todo[]
onToggle: (id: string) => void
onDelete: (id: string) => void
onUpdate: (id: string, updates: Partial<Todo>) => void
}
export function TodoList({ todos, onToggle, onDelete, onUpdate }: TodoListProps) {
// 空状態の表示
if (todos.length === 0) {
return (
<div className="empty-state">
<p>タスクがありません</p>
<p className="empty-hint">上のフォームから新しいタスクを追加しましょう</p>
</div>
)
}
return (
<div className="todo-list">
{todos.map(todo => (
// key={todo.id} でReactが差分更新を正しく行えるようにする
<TodoItem
key={todo.id}
todo={todo}
onToggle={onToggle}
onDelete={onDelete}
onUpdate={onUpdate}
/>
))}
</div>
)
}JavaScript版
import { TodoItem } from './TodoItem'
export function TodoList({ todos, onToggle, onDelete, onUpdate }) {
if (todos.length === 0) {
return (
<div className="empty-state">
<p>タスクがありません</p>
<p className="empty-hint">上のフォームから新しいタスクを追加しましょう</p>
</div>
)
}
return (
<div className="todo-list">
{todos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={onToggle}
onDelete={onDelete}
onUpdate={onUpdate}
/>
))}
</div>
)
}
6. メインアプリケーション
すべてのコンポーネントを組み合わせる App.tsx です。
カスタムフック useTodos から必要な値と関数を受け取り、各コンポーネントに渡すだけというシンプルな構造になっています。
// App.tsx
import { useTodos } from './hooks/useTodos'
import { TodoForm } from './components/TodoForm'
import { TodoFilters } from './components/TodoFilters'
import { TodoList } from './components/TodoList'
import './App.css'
function App() {
const {
todos, stats,
filter, setFilter,
searchTerm, setSearchTerm,
sortBy, setSortBy,
addTodo, deleteTodo, updateTodo, toggleTodo,
clearCompleted, toggleAll
} = useTodos()
return (
<div className="app">
<header className="app-header">
<h1>📝 Todo アプリ</h1>
<p className="subtitle">タスクを管理して生産性を向上させよう</p>
</header>
<main className="app-main">
<TodoForm onAdd={addTodo} />
<TodoFilters
filter={filter}
setFilter={setFilter}
searchTerm={searchTerm}
setSearchTerm={setSearchTerm}
sortBy={sortBy}
setSortBy={setSortBy}
stats={stats}
onClearCompleted={clearCompleted}
/>
{/* Todoが1件以上あるときだけ一括操作ボタンを表示 */}
{stats.total > 0 && (
<div className="bulk-actions">
<button onClick={toggleAll} className="btn-toggle-all">
すべて選択/解除
</button>
</div>
)}
<TodoList
todos={todos}
onToggle={toggleTodo}
onDelete={deleteTodo}
onUpdate={updateTodo}
/>
</main>
<footer className="app-footer">
<p>ダブルクリックで編集 | チェックで完了</p>
</footer>
</div>
)
}
export default AppJavaScript版
import { useTodos } from './hooks/useTodos'
import { TodoForm } from './components/TodoForm'
import { TodoFilters } from './components/TodoFilters'
import { TodoList } from './components/TodoList'
import './App.css'
function App() {
const {
todos, stats,
filter, setFilter,
searchTerm, setSearchTerm,
sortBy, setSortBy,
addTodo, deleteTodo, updateTodo, toggleTodo,
clearCompleted, toggleAll
} = useTodos()
return (
<div className="app">
<header className="app-header">
<h1>📝 Todo アプリ</h1>
<p className="subtitle">タスクを管理して生産性を向上させよう</p>
</header>
<main className="app-main">
<TodoForm onAdd={addTodo} />
<TodoFilters
filter={filter} setFilter={setFilter}
searchTerm={searchTerm} setSearchTerm={setSearchTerm}
sortBy={sortBy} setSortBy={setSortBy}
stats={stats} onClearCompleted={clearCompleted}
/>
{stats.total > 0 && (
<div className="bulk-actions">
<button onClick={toggleAll} className="btn-toggle-all">すべて選択/解除</button>
</div>
)}
<TodoList todos={todos} onToggle={toggleTodo} onDelete={deleteTodo} onUpdate={updateTodo} />
</main>
<footer className="app-footer">
<p>ダブルクリックで編集 | チェックで完了</p>
</footer>
</div>
)
}
export default App7. スタイリング
優先度の違いが一目でわかるよう、左ボーダーの色で視覚的に区別しています。
App.css:
/* App.css */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.app {
max-width: 800px;
margin: 0 auto;
background: white;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
/* ── ヘッダー ── */
.app-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
text-align: center;
}
.app-header h1 { font-size: 2.5rem; margin-bottom: 10px; }
.subtitle { opacity: 0.9; font-size: 1rem; }
.app-main { padding: 30px; }
/* ── 入力フォーム ── */
.todo-form { margin-bottom: 30px; }
.form-row { display: flex; gap: 10px; flex-wrap: wrap; }
.todo-input {
flex: 1;
min-width: 200px;
padding: 12px 16px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 1rem;
transition: border-color 0.3s;
}
.todo-input:focus { outline: none; border-color: #667eea; }
.priority-select,
.category-input {
padding: 12px 16px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 1rem;
}
.add-button {
padding: 12px 24px;
background: #667eea;
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.3s;
}
.add-button:hover { background: #5568d3; }
/* ── フィルター ── */
.todo-filters {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
}
.search-box { margin-bottom: 15px; }
.search-input {
width: 100%;
padding: 10px 16px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 1rem;
}
.filter-buttons { display: flex; gap: 10px; margin-bottom: 15px; flex-wrap: wrap; }
.filter-buttons button {
padding: 8px 16px;
border: 2px solid #e0e0e0;
background: white;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s;
}
.filter-buttons button.active {
background: #667eea;
color: white;
border-color: #667eea;
}
.sort-box { display: flex; align-items: center; gap: 10px; }
.sort-box select { padding: 8px 12px; border: 2px solid #e0e0e0; border-radius: 8px; }
.btn-clear {
margin-top: 10px;
padding: 8px 16px;
background: #ef4444;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
}
/* ── 一括操作 ── */
.bulk-actions { margin-bottom: 20px; }
.btn-toggle-all {
padding: 10px 20px;
border: 2px solid #667eea;
background: white;
color: #667eea;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
}
/* ── Todoリスト ── */
.todo-list { display: flex; flex-direction: column; gap: 12px; }
.todo-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
background: white;
border: 2px solid #e0e0e0;
border-radius: 8px;
transition: all 0.3s;
}
.todo-item:hover {
border-color: #667eea;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.1);
}
/* 完了状態 */
.todo-item.completed { opacity: 0.6; }
.todo-item.completed .todo-text { text-decoration: line-through; }
/* 優先度ごとに左ボーダーの色を変える */
.todo-item.priority-high { border-left: 4px solid #ef4444; }
.todo-item.priority-medium { border-left: 4px solid #f59e0b; }
.todo-item.priority-low { border-left: 4px solid #10b981; }
.todo-content { display: flex; align-items: center; gap: 12px; flex: 1; }
.todo-checkbox { width: 20px; height: 20px; cursor: pointer; }
.todo-text-wrapper { display: flex; flex-direction: column; gap: 4px; flex: 1; }
.todo-text { font-size: 1rem; cursor: pointer; }
.todo-category {
display: inline-block;
padding: 2px 8px;
background: #e0e7ff;
color: #667eea;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
}
.todo-date { font-size: 0.75rem; color: #9ca3af; }
.todo-edit-input {
flex: 1;
padding: 8px;
border: 2px solid #667eea;
border-radius: 4px;
font-size: 1rem;
}
.todo-actions { display: flex; gap: 8px; }
.todo-actions button {
padding: 6px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
transition: all 0.3s;
}
.btn-edit { background: #3b82f6; color: white; }
.btn-edit:hover { background: #2563eb; }
.btn-delete { background: #ef4444; color: white; }
.btn-delete:hover { background: #dc2626; }
.btn-save { background: #10b981; color: white; }
.btn-cancel { background: #6b7280; color: white; }
/* ── 空状態 ── */
.empty-state { text-align: center; padding: 60px 20px; color: #9ca3af; }
.empty-state p { font-size: 1.25rem; margin-bottom: 10px; }
.empty-hint { font-size: 1rem; }
/* ── フッター ── */
.app-footer {
background: #f8f9fa;
padding: 20px;
text-align: center;
color: #6b7280;
font-size: 0.875rem;
}
/* ── レスポンシブ対応 ── */
@media (max-width: 768px) {
.form-row { flex-direction: column; }
.todo-item {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.todo-actions { width: 100%; justify-content: flex-end; }
}8. 追加機能の実装
基本機能が完成したら、以下の機能を追加でき、アプリの完成度がさらに上がります。
ダークモード
document.body にクラスを付け外しすることでダークモードを実現します。
設定はローカルストレージに保存され、リロード後も引き継がれます。
// hooks/useDarkMode.ts
import { useState, useEffect } from 'react'
export function useDarkMode() {
const [isDark, setIsDark] = useState(() => {
// 初期値をローカルストレージから読み込む(遅延初期化)
const saved = localStorage.getItem('darkMode')
return saved ? JSON.parse(saved) : false
})
useEffect(() => {
localStorage.setItem('darkMode', JSON.stringify(isDark))
document.body.classList.toggle('dark-mode', isDark)
}, [isDark])
return [isDark, setIsDark] as const
}App.tsx への組み込み方:
// App.tsx
import { useDarkMode } from './hooks/useDarkMode'
function App() {
const [isDark, setIsDark] = useDarkMode()
// ...
return (
<div className="app">
<header className="app-header">
<h1>📝 Todo アプリ</h1>
<button onClick={() => setIsDark(!isDark)} className="theme-toggle">
{isDark ? '☀️' : '🌙'}
</button>
</header>
{/* ... */}
</div>
)
}
useStateの遅延初期化とは?useState(() => { ... })のように関数を渡すと、初回レンダリング時だけその関数が実行されます。
localStorage.getItemのような重い処理を毎回実行しないための最適化です。
/* App.css
/* ・・・・前のコード ・・・ */
/* ── テーマ切り替えボタン ── */
.theme-toggle {
background: transparent;
border: 2px solid rgba(255, 255, 255, 0.6);
border-radius: 8px;
padding: 6px 12px;
font-size: 1.2rem;
cursor: pointer;
transition: background 0.3s;
}
.theme-toggle:hover {
background: rgba(255, 255, 255, 0.15);
}
/* ── ダークモード ── */
/* body に .dark-mode クラスが付いたとき、配下の要素に上書き適用される */
body.dark-mode {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
}
body.dark-mode .app {
background: #1e1e2e;
color: #cdd6f4;
}
body.dark-mode .app-main {
background: #1e1e2e;
}
body.dark-mode .todo-input,
body.dark-mode .priority-select,
body.dark-mode .category-input,
body.dark-mode .search-input,
body.dark-mode .sort-box select {
background: #313244;
border-color: #45475a;
color: #cdd6f4;
}
body.dark-mode .todo-filters {
background: #181825;
}
body.dark-mode .filter-buttons button {
background: #313244;
border-color: #45475a;
color: #cdd6f4;
}
body.dark-mode .filter-buttons button.active {
background: #667eea;
border-color: #667eea;
color: white;
}
body.dark-mode .todo-item {
background: #313244;
border-color: #45475a;
color: #cdd6f4;
}
body.dark-mode .todo-item:hover {
border-color: #667eea;
}
body.dark-mode .todo-date {
color: #6c7086;
}
body.dark-mode .app-footer {
background: #181825;
color: #6c7086;
}データのエクスポート/インポート
Todoデータを JSON ファイルとして書き出したり、読み込んだりできます。
バックアップや別ブラウザへの移行に便利です。
// utils/export.ts
import { type Todo } from '../types/todo'
// Todoリストを JSON ファイルとしてダウンロード
export const exportTodos = (todos: Todo[]) => {
const dataStr = JSON.stringify(todos, null, 2)
const dataBlob = new Blob([dataStr], { type: 'application/json' })
const url = URL.createObjectURL(dataBlob)
// 仮のリンク要素を作ってクリックすることでダウンロードさせる
const link = document.createElement('a')
link.href = url
link.download = `todos-${new Date().toISOString()}.json`
link.click()
URL.revokeObjectURL(url) // メモリ解放
}
// JSON ファイルを読み込んで Todo の配列として返す
export const importTodos = (file: File): Promise<Todo[]> => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = (e) => {
try {
const data = JSON.parse(e.target?.result as string)
const todos = data.map((todo: any) => ({
...todo,
createdAt: new Date(todo.createdAt) // Dateオブジェクトに変換
}))
resolve(todos)
} catch (error) {
reject(new Error('無効なファイル形式です'))
}
}
reader.onerror = () => reject(new Error('ファイルの読み込みに失敗しました'))
reader.readAsText(file)
})
}// hooks/useTodos.ts
// ......
// インポートしたTodoで全件上書き
const importTodosData = (imported: Todo[]): void => {
setTodos(imported)
}
// コンポーネントが必要なものだけを返す
return {
//.......
//toggleAll,の後に追記
importTodosData
}- importTodosDataを追記
- returnのブロックにimportTodosDataを追記
// App.tsx
import { useTodos } from './hooks/useTodos'
import { TodoForm } from './components/TodoForm'
import { TodoFilters } from './components/TodoFilters'
import { TodoList } from './components/TodoList'
import './App.css'
import { useDarkMode } from './hooks/useDarkMode'
import { exportTodos, importTodos } from './utils/export'
function App() {
const [isDark, setIsDark] = useDarkMode()
const {
todos,
importTodosData,//①追記
stats,
filter, setFilter,
searchTerm, setSearchTerm,
sortBy, setSortBy,
addTodo, deleteTodo, updateTodo, toggleTodo,
clearCompleted, toggleAll
} = useTodos()
{/* ↓ ②追記------------------*/}
const handleImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
try {
const imported = await importTodos(file)
importTodosData(imported)
} catch (err) {
console.error(err)
}
}
{/* ↑ ②追記--------------*/}
return (
<div className="app">
<header className="app-header">
<h1>📝 Todo アプリ</h1>
<button onClick={() => setIsDark(!isDark)} className="theme-toggle">
{isDark ? '☀️' : '🌙'}
</button>
{/* ↓ ③追記------------------*/}
<div className="data-actions">
<button onClick={() => exportTodos(todos)}>
エクスポート
</button>
<label className="import-label">
インポート
<input
type="file"
accept=".json"
onChange={handleImport}
style={{ display: 'none' }}
/>
</label>
</div>
{/* ↑ ③追記--------------*/}
</header>
<main className="app-main">
{/*------- 省略 --------------*/}
</main>
</div>
)
}
export default App①〜③箇所追記
統計ダッシュボード
タスクの達成状況や優先度の内訳を可視化するコンポーネントです。
components/TodoStats.tsx:
// components/TodoStats.tsx
import { type Todo } from '../types/todo'
interface TodoStatsProps {
todos: Todo[]
}
export function TodoStats({ todos }: TodoStatsProps) {
const total = todos.length
const completed = todos.filter(t => t.completed).length
const active = total - completed
const completionRate = total > 0 ? Math.round((completed / total) * 100) : 0
// 優先度別の未完了タスク数
const byPriority = {
high: todos.filter(t => t.priority === 'high' && !t.completed).length,
medium: todos.filter(t => t.priority === 'medium' && !t.completed).length,
low: todos.filter(t => t.priority === 'low' && !t.completed).length
}
return (
<div className="todo-stats">
<h3>統計</h3>
<div className="stats-grid">
<div className="stat-card">
<div className="stat-value">{total}</div>
<div className="stat-label">総タスク数</div>
</div>
<div className="stat-card">
<div className="stat-value">{active}</div>
<div className="stat-label">未完了</div>
</div>
<div className="stat-card">
<div className="stat-value">{completed}</div>
<div className="stat-label">完了</div>
</div>
<div className="stat-card">
<div className="stat-value">{completionRate}%</div>
<div className="stat-label">達成率</div>
</div>
</div>
<div className="priority-stats">
<h4>優先度別(未完了)</h4>
<div className="priority-bars">
{(['high', 'medium', 'low'] as const).map(level => (
<div key={level} className="priority-bar">
<span>{{ high: '高', medium: '中', low: '低' }[level]}</span>
<div className={`bar ${level}`} style={{ width: `${byPriority[level] * 10}%` }}>
{byPriority[level]}
</div>
</div>
))}
</div>
</div>
</div>
)
}/* App.ts */
/* ── 統計ダッシュボード ── */
.todo-stats {
background: #f8f9fa;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.todo-stats h3 {
font-size: 1rem;
font-weight: 600;
color: #374151;
margin-bottom: 16px;
}
/* 4つの数値カードを横並びに */
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 20px;
}
.stat-card {
background: white;
border-radius: 8px;
padding: 16px;
text-align: center;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
}
.stat-value {
font-size: 1.75rem;
font-weight: 700;
color: #667eea;
line-height: 1;
margin-bottom: 6px;
}
.stat-label {
font-size: 0.75rem;
color: #6b7280;
}
/* 優先度バー */
.priority-stats h4 {
font-size: 0.875rem;
color: #374151;
margin-bottom: 10px;
}
.priority-bars {
display: flex;
flex-direction: column;
gap: 8px;
}
.priority-bar {
display: flex;
align-items: center;
gap: 10px;
}
.priority-bar span {
width: 1.5rem;
font-size: 0.75rem;
color: #6b7280;
text-align: right;
flex-shrink: 0;
}
.priority-bar .bar {
height: 20px;
border-radius: 4px;
min-width: 28px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 600;
color: white;
transition: width 0.3s ease;
}
.priority-bar .bar.high { background: #ef4444; }
.priority-bar .bar.medium { background: #f59e0b; }
.priority-bar .bar.low { background: #10b981; }
/* ダークモード対応 */
body.dark-mode .todo-stats {
background: #181825;
}
body.dark-mode .todo-stats h3,
body.dark-mode .priority-stats h4 {
color: #cdd6f4;
}
body.dark-mode .stat-card {
background: #313244;
box-shadow: none;
}
body.dark-mode .stat-label,
body.dark-mode .priority-bar span {
color: #6c7086;
}
/* レスポンシブ:小さい画面では2列に */
@media (max-width: 480px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}`9. テスト
UIの動作を自動でチェックするテストを書いておくと、機能追加・修正のたびに手動確認する手間を減らせます。
ここでは Vitest(テストランナー)と Testing Library(UI操作のユーティリティ)を組み合わせて使います。
Vitest vs Jest
Viteプロジェクトでは、同じViteの設定を共有できる Vitest が相性よく動作します。
APIはJestとほぼ同じなので、Jestの経験があればすぐ使えます。
9-1. セットアップ
まず必要なパッケージをインストールします。
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom| パッケージ | 役割 |
|---|---|
vitest | テストの実行エンジン |
@testing-library/react | Reactコンポーネントをレンダリングして操作するユーティリティ |
@testing-library/jest-dom | toBeInTheDocument() などのカスタムマッチャーを追加 |
@testing-library/user-event | クリック・入力などをより実際のユーザー操作に近い形でシミュレート |
jsdom | ブラウザ環境をNode.js上でエミュレート |
9-2. Viteの設定
vite.config.ts(または vite.config.js)にテスト設定を追加します。
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
globals: true, // describe / it / expect をimportなしで使えるようにする
environment: 'jsdom', // ブラウザAPIをNode.jsでエミュレート
setupFiles: './src/setupTests.ts', // 各テスト前の共通設定ファイル
},
})9-3. セットアップファイルの作成
Testing Library のカスタムマッチャーを有効にするため、セットアップファイルを作ります。
// src/setupTests.ts
import '@testing-library/jest-dom'このファイルを書いておくことで、
expect(...).toBeInTheDocument()などのマッチャーが全テストで使えるようになります。
9-4. package.json にテストコマンドを追加
{
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"test": "vitest",
"test:ui": "vitest --ui",
"test:run": "vitest run",
"coverage": "vitest run --coverage"
}
}| コマンド | 動作 |
|---|---|
npm test | ウォッチモードで実行(ファイル変更のたびに自動再実行) |
npm run test:run | 1回だけ実行してCIなどで使う |
npm run test:ui | ブラウザUIでテスト結果を確認(別途 @vitest/ui が必要) |
npm run coverage | カバレッジレポートを生成 |
9-5. テストファイルを書く
テストファイルは src/ 以下に .test.tsx(JS版は .test.jsx)という名前で作ります。
src/App.test.tsx
// src/App.test.tsx`
import { describe, it, expect, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import App from './App'
describe('Todo App', () => {
// 各テスト前にローカルストレージをリセット(テスト間の干渉を防ぐ)
beforeEach(() => {
localStorage.clear()
})
it('アプリのタイトルが表示される', () => {
render(<App />)
expect(screen.getByText(/Todo アプリ/i)).toBeInTheDocument()
})
it('タスクを追加できる', () => {
render(<App />)
const input = screen.getByPlaceholderText(/新しいタスク/i)
const button = screen.getByRole('button', { name: /追加/i })
fireEvent.change(input, { target: { value: 'テストタスク' } })
fireEvent.click(button)
expect(screen.getByText('テストタスク')).toBeInTheDocument()
})
it('チェックボックスで完了状態を切り替えられる', () => {
render(<App />)
fireEvent.change(screen.getByPlaceholderText(/新しいタスク/i), { target: { value: 'テストタスク' } })
fireEvent.click(screen.getByRole('button', { name: /追加/i }))
const checkbox = screen.getByRole('checkbox')
fireEvent.click(checkbox)
expect(checkbox).toBeChecked()
})
it('削除ボタンでタスクを消せる', () => {
render(<App />)
fireEvent.change(screen.getByPlaceholderText(/新しいタスク/i), { target: { value: 'テストタスク' } })
fireEvent.click(screen.getByRole('button', { name: /追加/i }))
fireEvent.click(screen.getByText(/削除/i))
expect(screen.queryByText('テストタスク')).not.toBeInTheDocument()
})
it('完了済みのタスクだけフィルタリングできる', () => {
render(<App />)
// タスクを2件追加
const input = screen.getByPlaceholderText(/新しいタスク/i)
const addButton = screen.getByRole('button', { name: /追加/i })
fireEvent.change(input, { target: { value: 'タスクA' } })
fireEvent.click(addButton)
fireEvent.change(input, { target: { value: 'タスクB' } })
fireEvent.click(addButton)
// タスクAだけ完了にする
const checkboxes = screen.getAllByRole('checkbox')
fireEvent.click(checkboxes[1])
// 「完了」フィルターに切り替え
fireEvent.click(screen.getByRole('button', { name: '完了 (1)' }))
// タスクAは表示、タスクBは非表示
expect(screen.getByText('タスクA')).toBeInTheDocument()
expect(screen.queryByText('タスクB')).not.toBeInTheDocument()
})
})9-6. テストを実行する
ターミナルでプロジェクトルートに移動し、以下を実行します。
npm test成功すると、以下のような出力が表示されます。
✓ src/App.test.tsx (5)
✓ Todo App > アプリのタイトルが表示される
✓ Todo App > タスクを追加できる
✓ Todo App > チェックボックスで完了状態を切り替えられる
✓ Todo App > 削除ボタンでタスクを消せる
✓ Todo App > 完了済みのタスクだけフィルタリングできる
Test Files 1 passed (1)
Tests 5 passed (5)
Duration 1.23sテストが失敗した場合は、どのアサーションが失敗したかと期待値・実際の値が表示されます。
FAIL src/App.test.tsx > Todo App > タスクを追加できる
AssertionError: expected element to be in the document
- Expected "テストタスク" to be in the document
+ Received null9-7. よく使うマッチャーとクエリ
要素の取得(screen.*)
| クエリ | 使いどころ |
|---|---|
getByText('...') | テキストで要素を取得(見つからないとエラー) |
getByPlaceholderText('...') | input のplaceholder属性で取得 |
getByRole('button') | ARIA ロールで取得(アクセシビリティに沿った指定) |
queryByText('...') | 要素が存在しない場合を確認したいとき(見つからなくてもnullを返す) |
findByText('...') | 非同期で現れる要素を待って取得(Promiseを返す) |
アサーション(expect(...).***)
| マッチャー | 確認できること |
|---|---|
toBeInTheDocument() | 要素がDOMに存在する |
not.toBeInTheDocument() | 要素がDOMに存在しない |
toBeChecked() | チェックボックスがチェックされている |
toHaveTextContent('...') | 要素が指定のテキストを含む |
toHaveValue('...') | inputの値が一致する |
Testing Libraryの考え方
ユーザーが実際に目にする要素(テキスト・プレースホルダー・ロール)で要素を取得することで、実装の詳細(クラス名やid)に依存しないテストが書けます。10. デプロイ
完成したアプリを公開してみましょう。
VercelとNetlifyどちらも無料で使えるサービスです。
デプロイに関しては、#20の記事で解説します。
まとめ
この記事では、実践的なTodoアプリを通じて以下を学びました。
実装した機能
| 機能 | 説明 |
|---|---|
| Todo CRUD | 追加・編集・削除・完了切り替え |
| 優先度設定 | 高・中・低の3段階、左ボーダーで視覚化 |
| カテゴリ | 任意のタグを付けて分類 |
| フィルタリング | すべて・未完了・完了の3種 |
| 検索 | テキスト・カテゴリでリアルタイム絞り込み |
| ソート | 作成日時・優先度・名前で並び替え |
| データ永続化 | ローカルストレージに自動保存 |
| 統計ダッシュボード | 達成率・優先度別件数を表示 |
| ダークモード | テーマ切り替え・設定を保存 |
| エクスポート/インポート | JSON形式でデータをバックアップ |
学んだ技術のポイント
- カスタムフックでロジックをUIから分離し、コンポーネントをシンプルに保てる
- TypeScriptの型定義により、IDEの補完が効いてミスを事前に防げる
- localStorageの操作は try-catch とDate変換を忘れずに
- コンポーネント設計は「表示の関心事」と「ロジックの関心事」を分けることが基本
さらに発展させるアイデア
- ドラッグ&ドロップで並び替え(
@dnd-kitライブラリが便利) - 期限設定とリマインダー(
date-fnsで日付操作) - サブタスク(再帰的なコンポーネント設計)
- バックエンドAPIとの連携(
fetchやaxios+ REST API) - PWA化でオフライン対応
次のステップ:
次回からは、より高度なトピックに進みます。
useContextによるグローバル状態管理、パフォーマンス最適化、TypeScriptの高度な型定義などを学んでいきましょう!


























