ReactでTodoアプリを作る #3 フィルタリング
前回は完了トグルと統計を追加しました。今回はフィルタリング(全部/未完了/完了)と一括処理(完了項目の一括削除)を作ってみます。
今回のステップの目標 #
- 画面上部に「全部 / 未完了 / 完了」フィルタボタン
- クリックするとその条件に合う項目だけ表示
- 統計エリアに「完了項目をすべて削除」ボタン(完了項目があるときだけ)
- 1項目もないとき、フィルタに合う項目がないとき、それぞれ別の案内文
フィルタstateを追加 #
フィルタリングは画面表示の方式であってデータ自体の変更ではありません。todos自体はそのままにして、見せる方式だけ変えるアプローチが定石です。
TodoAppにfilter stateをもう1つ置きます。
const [filter, setFilter] = useState('all'); // 'all' | 'active' | 'completed'画面に表示する一覧はtodosとfilterから計算します。
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:
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:
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:
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:
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)もデザインの一部というマインドセットが実戦開発で重要です。
動作確認 #
保存して次を試してみてください。
- タスクをいくつか追加して一部を完了表示
- 「未完了」フィルタクリック → 未完了項目だけ表示
- 「完了」フィルタクリック → 完了した項目だけ表示
- 「全部」フィルタに戻る
- 「完了項目をすべて削除」ボタンクリック → 完了した項目が一度に消える
- すべてのタスクを削除 → 「タスクがありません」案内
- すべてのタスクを未完了状態にして「完了」フィルタ → 「完了項目がありません」案内
統計の位置についての決定 #
今、統計と一括削除ボタンがTodoStatsの中に一緒にあります。別の選択も可能でした。
- 統計と一括削除を別のコンポーネントに分離
- 統計は上に置いて一括削除は一覧の下に置く
正解はありません。今回のシリーズでは関連する情報を近くに置く方を選びました。「完了がN個ある」という情報の隣に「それを一度に整理してください」ボタンがある方がユーザーにとって自然なので。
設計の決定を下すときは決まった答えを探そうとするより「なぜこうしたかを一行で説明できるか?」程度で十分です。後で他の選択の方がよく見えたらそのとき変えればよいですから。
?filter=active)に反映するとリロードしてもフィルタが維持され、URLを共有すると同じ画面にすぐ行けます。#15で扱ったuseSearchParamsで可能ですが、このシリーズでは単純化のためにメモリstateにしました。ルーティングのある大きなアプリならURL側の方がよいかもしれません。おわりに #
今回の記事ではフィルタリングと一括処理を追加しました。
- フィルタstateは別途置き、表示する一覧は
todosとfilterから計算 - 同じデータから統計は全体で、一覧はフィルタリングされたもので — 子ごとに異なる加工結果を渡す
- 空状態も状況に応じて異なる案内
- フィルタオプションをデータ配列にして
mapでレンダリングするパターン
これまで作ったアプリは追加、完了トグル、削除、フィルタリングまで可能です。しかし一度入力したテキストは修正する方法がありません。次の記事「ReactでTodoアプリを作る #4 編集機能」では項目をダブルクリックするとインライン編集モードに入り、Enterで保存 / Escapeでキャンセルする機能を作ってみます。この過程で初めてuseRefも登場します。