ReactでTodoアプリを作る #3 フィルタリング

読了 6分

前回は完了トグルと統計を追加しました。今回はフィルタリング(全部/未完了/完了)と一括処理(完了項目の一括削除)を作ってみます。

今回のステップの目標 #

  • 画面上部に「全部 / 未完了 / 完了」フィルタボタン
  • クリックするとその条件に合う項目だけ表示
  • 統計エリアに「完了項目をすべて削除」ボタン(完了項目があるときだけ)
  • 1項目もないとき、フィルタに合う項目がないとき、それぞれ別の案内文

フィルタstateを追加 #

フィルタリングは画面表示の方式であってデータ自体の変更ではありません。todos自体はそのままにして、見せる方式だけ変えるアプローチが定石です。

TodoAppfilter stateをもう1つ置きます。

src/TodoApp.jsx
const [filter, setFilter] = useState('all');  // 'all' | 'active' | 'completed'

画面に表示する一覧はtodosfilterから計算します。

フィルタリングされた一覧の計算
const filteredTodos = todos.filter(todo => {
  if (filter === 'active') return !todo.completed;
  if (filter === 'completed') return todo.completed;
  return true;
});

もう一度派生値はstateにしない原則です。filteredTodosは毎回のレンダリングごとに計算されますが、Todoアプリの規模ではコストが無視できるレベルです。

TodoFilterコンポーネント #

フィルタボタングループを別のコンポーネントに切り出します。

src/TodoFilter.jsx:

src/TodoFilter.jsx
const FILTERS = [
  { value: 'all', label: '全体' },
  { value: 'active', label: '未完了' },
  { value: 'completed', label: '完了' },
];

function TodoFilter({ filter, onChange }) {
  return (
    <div style={{ display: 'flex', gap: '4px', marginBottom: '12px' }}>
      {FILTERS.map(item => (
        <button
          key={item.value}
          onClick={() => onChange(item.value)}
          style={{
            padding: '4px 12px',
            border: '1px solid #ccc',
            background: filter === item.value ? '#333' : '#fff',
            color: filter === item.value ? '#fff' : '#333',
            cursor: 'pointer',
          }}
        >
          {item.label}
        </button>
      ))}
    </div>
  );
}

export default TodoFilter;

核心パターン:

  • フィルタオプションをデータ配列(FILTERS)として置きmapで描く(#8) — オプションを追加/修正するときにJSXを触らなくてもよい
  • 現在選択されたフィルタは視覚的に区別(背景色/文字色を反転)
  • 変更はコールバック(onChange)で親に通知

TodoStatsに一括削除ボタン #

完了項目が1つでもあるときだけ「完了項目をすべて削除」ボタンを表示します。条件付きレンダリング(#7)が自然に入ります。

src/TodoStats.jsx:

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

  return (
    <div style={{
      display: 'flex',
      justifyContent: 'space-between',
      alignItems: 'center',
      padding: '8px 0',
      fontSize: '14px',
      color: '#555',
      borderBottom: '1px solid #eee',
    }}>
      <span>全体 {total} · 残り {remaining} · 完了 {completed}</span>
      {completed > 0 && (
        <button onClick={onClearCompleted} style={{ fontSize: '12px' }}>
          完了項目をすべて削除
        </button>
      )}
    </div>
  );
}

export default TodoStats;

completed > 0 && ... — #7で押さえた落とし穴を覚えていますね?左側が明示的なブーリアン比較なので安全です(completed && ...と書くと0のとき画面に0が出力される危険)。

TodoApp統合 #

ではすべてのピースをまとめてみます。

src/TodoApp.jsx:

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

function TodoApp() {
  const [todos, setTodos] = useState([]);
  const [filter, setFilter] = useState('all');

  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
    ));
  }

  function clearCompleted() {
    setTodos(prev => prev.filter(todo => !todo.completed));
  }

  const filteredTodos = todos.filter(todo => {
    if (filter === 'active') return !todo.completed;
    if (filter === 'completed') return todo.completed;
    return true;
  });

  return (
    <div style={{ maxWidth: '500px', margin: '0 auto', padding: '24px' }}>
      <h1>Todo</h1>
      <TodoForm onAdd={addTodo} />
      <TodoFilter filter={filter} onChange={setFilter} />
      <TodoStats todos={todos} onClearCompleted={clearCompleted} />
      <TodoList
        todos={filteredTodos}
        filter={filter}
        totalCount={todos.length}
        onToggle={toggleTodo}
        onDelete={deleteTodo}
      />
    </div>
  );
}

export default TodoApp;

ここで1つ意図的な決定があります。

  • TodoStatsにはtodos全体を渡す(フィルタに関係なく全体の統計を見せる必要があるので)
  • TodoListにはfilteredTodosを渡す(現在のフィルタに合うものだけ表示)

同じtodos stateから2つのコンポーネントが異なる加工値を受け取る形です。この分離が可能な理由は真実の源がTodoAppにあり、子たちは受け取ったデータだけ描くからです。

TodoList — 空状態の分岐を精緻化 #

これまでTodoListは「タスクがありません」というメッセージを1つだけ表示していました。しかしフィルタを適用したときの空の状態は「フィルタに合うものがない」という意味であって「タスク自体がない」という意味ではありません。案内文を分岐しましょう。

src/TodoList.jsx:

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

const FILTER_LABEL = {
  all: 'タスク',
  active: '未完了の項目',
  completed: '完了した項目',
};

function TodoList({ todos, filter, totalCount, onToggle, onDelete }) {
  if (todos.length === 0) {
    if (totalCount === 0) {
      return <p style={{ color: '#888' }}>タスクがありません新しく追加してみてください</p>;
    }
    return <p style={{ color: '#888' }}>{FILTER_LABEL[filter]}がありません</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;
  • 全件数(totalCount)が0 → 「タスクがありません」
  • 全体はあるけれどフィルタリングされた結果だけ0 → 「○○がありません」

小さな違いですがユーザー体験は明らかに良くなります。空の状態(empty state)もデザインの一部というマインドセットが実戦開発で重要です。

動作確認 #

保存して次を試してみてください。

  1. タスクをいくつか追加して一部を完了表示
  2. 「未完了」フィルタクリック → 未完了項目だけ表示
  3. 「完了」フィルタクリック → 完了した項目だけ表示
  4. 「全部」フィルタに戻る
  5. 「完了項目をすべて削除」ボタンクリック → 完了した項目が一度に消える
  6. すべてのタスクを削除 → 「タスクがありません」案内
  7. すべてのタスクを未完了状態にして「完了」フィルタ → 「完了項目がありません」案内

統計の位置についての決定 #

今、統計と一括削除ボタンがTodoStatsの中に一緒にあります。別の選択も可能でした。

  • 統計と一括削除を別のコンポーネントに分離
  • 統計は上に置いて一括削除は一覧の下に置く

正解はありません。今回のシリーズでは関連する情報を近くに置く方を選びました。「完了がN個ある」という情報の隣に「それを一度に整理してください」ボタンがある方がユーザーにとって自然なので。

設計の決定を下すときは決まった答えを探そうとするより「なぜこうしたかを一行で説明できるか?」程度で十分です。後で他の選択の方がよく見えたらそのとき変えればよいですから。

ヒント
フィルタをURLクエリ(?filter=active)に反映するとリロードしてもフィルタが維持され、URLを共有すると同じ画面にすぐ行けます。#15で扱ったuseSearchParamsで可能ですが、このシリーズでは単純化のためにメモリstateにしました。ルーティングのある大きなアプリならURL側の方がよいかもしれません。

おわりに #

今回の記事ではフィルタリングと一括処理を追加しました。

  • フィルタstateは別途置き、表示する一覧はtodosfilterから計算
  • 同じデータから統計は全体で、一覧はフィルタリングされたもので — 子ごとに異なる加工結果を渡す
  • 空状態も状況に応じて異なる案内
  • フィルタオプションをデータ配列にしてmapでレンダリングするパターン

これまで作ったアプリは追加、完了トグル、削除、フィルタリングまで可能です。しかし一度入力したテキストは修正する方法がありません。次の記事「ReactでTodoアプリを作る #4 編集機能」では項目をダブルクリックするとインライン編集モードに入り、Enterで保存 / Escapeでキャンセルする機能を作ってみます。この過程で初めてuseRefも登場します。

X