ReactでTodoアプリを作る #2 完了トグルと統計

読了 6分

前回は追加/削除まで動作するTodoアプリの骨組みを作りました。今回は各項目にチェックボックスで完了表示をし、残り件数 / 全件数を表示する統計エリアを追加してみます。

今回のステップの目標 #

  • 項目の横のチェックボックスをクリック → 完了/未完了をトグル
  • 完了した項目は視覚的に区別(グレー、取り消し線)
  • 画面のどこかに「全N件 / 残りM件」を表示

トグルハンドラを追加 #

データフローは前回の記事と同じです。stateはTodoAppに、変更はコールバックで親に通知。トグルハンドラをTodoAppに追加します。

src/TodoApp.jsx:

src/TodoApp.jsx
import { useState } from 'react';
import TodoForm from './TodoForm';
import TodoList from './TodoList';
import TodoStats from './TodoStats';

function TodoApp() {
  const [todos, setTodos] = useState([]);

  function addTodo(text) {
    const newTodo = {
      id: crypto.randomUUID(),
      text,
      completed: false,
    };
    setTodos(prev => [newTodo, ...prev]);
  }

  function deleteTodo(id) {
    setTodos(prev => prev.filter(todo => todo.id !== id));
  }

  function toggleTodo(id) {
    setTodos(prev => prev.map(todo =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    ));
  }

  return (
    <div style={{ maxWidth: '500px', margin: '0 auto', padding: '24px' }}>
      <h1>Todo</h1>
      <TodoForm onAdd={addTodo} />
      <TodoStats todos={todos} />
      <TodoList todos={todos} onToggle={toggleTodo} onDelete={deleteTodo} />
    </div>
  );
}

export default TodoApp;

追加された核心:

function toggleTodo(id) {
  setTodos(prev => prev.map(todo =>
    todo.id === id ? { ...todo, completed: !todo.completed } : todo
  ));
}

このパターンはよく登場するので覚えておくとよいです。

  • mapで新しい配列を作りながら
  • マッチする項目は新しいオブジェクトに置き換え({ ...todo, completed: !todo.completed })
  • それ以外はそのままにする

#5で学んだイミュータブル更新の標準形です。絶対にtodo.completed = !todo.completedのように直接変更しません — 同じオブジェクトの参照なのでReactが変化を検知できません。

TodoItemにチェックボックスをつける #

TodoItemにチェックボックスを追加し、完了状態のときに視覚的に区別します。

src/TodoItem.jsx:

src/TodoItem.jsx
function TodoItem({ todo, onToggle, onDelete }) {
  return (
    <li style={{
      display: 'flex',
      alignItems: 'center',
      gap: '8px',
      padding: '8px',
      borderBottom: '1px solid #eee',
      opacity: todo.completed ? 0.5 : 1,
    }}>
      <input
        type="checkbox"
        checked={todo.completed}
        onChange={() => onToggle(todo.id)}
      />
      <span style={{
        flex: 1,
        textDecoration: todo.completed ? 'line-through' : 'none',
      }}>
        {todo.text}
      </span>
      <button onClick={() => onDelete(todo.id)}>
        削除
      </button>
    </li>
  );
}

export default TodoItem;
  • checked={todo.completed}onChangeのペアでチェックボックスをcontrolledコンポーネントにする(#9)
  • 完了した項目はopacity: 0.5(半透明)とtext-decoration: line-through(取り消し線)で区別
  • インラインスタイルを条件によって分岐するよくあるパターン

TodoListにハンドラを渡す #

TodoListは受け取ったonToggleをそのまま子に下ろします。

src/TodoList.jsx:

src/TodoList.jsx
import TodoItem from './TodoItem';

function TodoList({ todos, onToggle, onDelete }) {
  if (todos.length === 0) {
    return <p style={{ color: '#888' }}>タスクがありません新しく追加してみてください</p>;
  }

  return (
    <ul style={{ listStyle: 'none', padding: 0 }}>
      {todos.map(todo => (
        <TodoItem
          key={todo.id}
          todo={todo}
          onToggle={onToggle}
          onDelete={onDelete}
        />
      ))}
    </ul>
  );
}

export default TodoList;

このように中間のコンポーネントが自分と無関係なpropsをただ下に渡す姿が見えると思いますが、これが#12で扱ったprop drillingです。Todoアプリ程度の規模では問題になるほどではありません(ツリーが浅くてコンポーネントが少ない)。もっと大きくなったらそのときContextを導入すればよいです。

TodoStats — 統計コンポーネント #

全件数と残り件数を表示するシンプルなコンポーネントです。データを受け取って計算するだけで表示します。

src/TodoStats.jsx:

src/TodoStats.jsx
function TodoStats({ todos }) {
  const total = todos.length;
  const remaining = todos.filter(todo => !todo.completed).length;
  const completed = total - remaining;

  return (
    <div style={{
      padding: '8px 0',
      fontSize: '14px',
      color: '#555',
      borderBottom: '1px solid #eee',
    }}>
      全体 {total} · 残り {remaining} · 完了 {completed}
    </div>
  );
}

export default TodoStats;

ここで押さえておきたい1点 — totalremainingcompletedstateにしませんでしたtodosからその都度計算できる値だからです。#11で見たSingle Source of Truth原則です。

計算可能な値はstateにしないでください。本物のstateはtodos1つだけ、統計はそこから派生する値。

もしtotalを別のstateにしていたらtodosが変わるたびにeffectで同期しなければならなかったでしょうし、同期漏れのようなバグの余地が生まれていたでしょう。ただ毎回のレンダリングごとに計算する方がシンプルで安全です。

useMemoは必要ないでしょうか? #

#14で学んだuseMemoを思い出した方もいるでしょう。「filterが毎回のレンダリングごとに回るのは非効率ではないか?」

答えは現在の規模ではまったく気にする必要なしです。項目が数万個ではなく数十〜数百個程度でしょうし、filterは非常に速い演算です。測定して本当に遅いときだけ最適化する原則(#14)をそのまま適用すればよいです。今はコードをシンプルに保つ方がはるかに価値があります。

動作確認 #

保存してブラウザで次を確認してみてください。

  1. タスクをいくつか追加
  2. チェックボックスを押すと項目が薄くなり取り消し線が表示
  3. 再度押すと元に戻る
  4. 統計エリアの「残り / 完了」の数字が即座に更新
  5. 項目を削除すると統計も自動更新

よくある間違いを押さえる #

1. オブジェクトを直接変更する #

間違った例
function toggleTodo(id) {
  const todo = todos.find(t => t.id === id);
  todo.completed = !todo.completed;  // 🚫
  setTodos([...todos]);
}

todo.completed = ...で直接変更してはいけません。同じオブジェクトの参照なのでReactが比較時に「同じオブジェクト」と判断する可能性があり、未来のReact Compilerのようなツールもイミュータビリティを前提に動作します。常に新しいオブジェクトを作って入れるパターン({ ...todo, completed: ... })を貫いてください。

2. チェックボックスにvalueを使う #

間違った例
<input type="checkbox" value={todo.completed} onChange={...} />

チェックボックスはvalueではなくcheckedを使います(#9)。valueはただform submit時に送信される値であって、チェックの有無とは無関係です。

3. onToggleをインデックス基準で #

間違った例
{todos.map((todo, index) => (
  <TodoItem onToggle={() => toggleByIndex(index)} ... />
))}

IDでトグルする方が安全です。インデックスで行うとソートやフィルタリングが適用された瞬間にインデックスと実際の項目のマッチングがずれる可能性があります。IDは項目自体に付いているのでどんな変換を経ても正確です。

おわりに #

今回の記事では2つを追加しました。

  • 完了トグル — チェックボックス + 視覚的区別 + イミュータブル更新のmapパターン
  • 統計 — 派生値はstateにせず計算

今、私たちのアプリはすべてのタスクを常に一度に表示します。タスクが多くなると「残ったものだけ見たい」とか「完了したものだけ整理して見たい」という欲求が生まれますね。次の記事「ReactでTodoアプリを作る #3 フィルタリング」では全部 / 未完了 / 完了フィルタを追加し、完了項目の一括削除のような一括処理機能も作ってみます。

X