デザインについての学習メモブログ

React入門 #15 – 実践!Todoアプリを作ってみよう

記事内に広告が含まれています。

React入門 #15 – 実践!Todoアプリを作ってみよう

これまで学んできた知識を総動員して、本格的なTodoアプリケーションを作成します。

ローカルストレージでのデータ永続化、フィルタリング、検索機能など、実用的な機能を実装しながら、実践的なReact開発の流れを体験しましょう。

この記事で学べること

  • コンポーネント設計のベストプラクティス
  • カスタムフックを使ったロジックの分離
  • TypeScriptによる型安全な開発
  • ローカルストレージを使ったデータ永続化
  • 検索・フィルタリング・ソート機能の実装

1. プロジェクトのセットアップ

まずは Vite を使って React + TypeScript のプロジェクトを作成します。

Vite は webpack より起動が速く、開発体験に優れているため、現在のReact開発では標準的な選択肢になっています。

プロジェクト作成

Bash
npm create vite@latest todo-app -- --template react-ts
cd todo-app
npm install
npm run dev

JavaScript版を使う場合
--template react-ts の代わりに --template react を指定すると、TypeScriptなしのプロジェクトが作れます。

この記事ではTS/JSどちらのコードも紹介します。

TSで書ける方を推奨しますので、この辺りからは、TSを中心に解説し補足説明としてJS版の記入例を紹介します。

ディレクトリ構成

機能ごとにファイルを分割することで、コードの見通しがよくなり、チーム開発やメンテナンスが楽になります。

Bash
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.tsx

2. 型定義

TypeScriptを使う場合、最初に「どんなデータを扱うか」を型として定義します。

型を先に決めておくことで、コード補完が効くようになり、ミスを事前に防ぎやすくなります。

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を保存します。

この処理をユーティリティ関数にまとめることで、コンポーネントからは「保存・読み込み」という操作だけを意識すればよくなります。

TypeScript
// 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版

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フックをひとつにまとめたものです。

コンポーネントから切り離すことで、テストや再利用がしやすくなります。

TypeScript
// 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版

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の役割に徹します(ロジックはカスタムフックに委ねる設計)。

TSX
// 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版

JSX
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を実装しています。

TSX
// 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版

JSX
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:

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版

JSX
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:

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版

JSX
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 から必要な値と関数を受け取り、各コンポーネントに渡すだけというシンプルな構造になっています。

TSX
// 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 App

JavaScript版

JSX
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 App

7. スタイリング

優先度の違いが一目でわかるよう、左ボーダーの色で視覚的に区別しています。

App.css:

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 にクラスを付け外しすることでダークモードを実現します。

設定はローカルストレージに保存され、リロード後も引き継がれます。

TypeScript
// 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 への組み込み方:

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 のような重い処理を毎回実行しないための最適化です。

CSS
/* 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 ファイルとして書き出したり、読み込んだりできます。

バックアップや別ブラウザへの移行に便利です。

TypeScript
// 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)
  })
}
TypeScript
// hooks/useTodos.ts

// ......

 // インポートしたTodoで全件上書き
  const importTodosData = (imported: Todo[]): void => {
    setTodos(imported)
  }

  // コンポーネントが必要なものだけを返す
  return {
  //.......
  //toggleAll,の後に追記
    importTodosData
  }
  • importTodosDataを追記
  • returnのブロックにimportTodosDataを追記
TSX
// 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:

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>
  )
}
CSS
/* 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. セットアップ

まず必要なパッケージをインストールします。

Bash
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
パッケージ役割
vitestテストの実行エンジン
@testing-library/reactReactコンポーネントをレンダリングして操作するユーティリティ
@testing-library/jest-domtoBeInTheDocument() などのカスタムマッチャーを追加
@testing-library/user-eventクリック・入力などをより実際のユーザー操作に近い形でシミュレート
jsdomブラウザ環境をNode.js上でエミュレート

9-2. Viteの設定

vite.config.ts(または vite.config.js)にテスト設定を追加します。

TypeScript
// 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 のカスタムマッチャーを有効にするため、セットアップファイルを作ります。

TypeScript
// src/setupTests.ts
import '@testing-library/jest-dom'

このファイルを書いておくことで、expect(...).toBeInTheDocument() などのマッチャーが全テストで使えるようになります。

9-4. package.json にテストコマンドを追加

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:run1回だけ実行してCIなどで使う
npm run test:uiブラウザUIでテスト結果を確認(別途 @vitest/ui が必要)
npm run coverageカバレッジレポートを生成

9-5. テストファイルを書く

テストファイルは src/ 以下に .test.tsx(JS版は .test.jsx)という名前で作ります。

src/App.test.tsx

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. テストを実行する

ターミナルでプロジェクトルートに移動し、以下を実行します。

Bash
npm test

成功すると、以下のような出力が表示されます。

Bash
  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

テストが失敗した場合は、どのアサーションが失敗したかと期待値・実際の値が表示されます。

Bash
 FAIL src/App.test.tsx > Todo App > タスクを追加できる

AssertionError: expected element to be in the document

 - Expected  "テストタスク" to be in the document
 + Received  null

9-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との連携(fetchaxios + REST API)
  • PWA化でオフライン対応

次のステップ:

次回からは、より高度なトピックに進みます。

useContextによるグローバル状態管理、パフォーマンス最適化、TypeScriptの高度な型定義などを学んでいきましょう!