Build a Todo App with React #3: Filtering
Last time we added the completion toggle and stats. This time we’ll build filtering (All/Active/Completed) and a bulk operation (clear completed items).
Goals for this step #
- “All / Active / Completed” filter buttons at the top
- Clicking a button shows only the items that match
- A “Clear completed” button in the stats area (only when there’s at least one completed item)
- Different empty-state messages for “no items at all” vs “no items match the filter”
Add filter state #
Filtering is a display concern, not a change to the data itself. The textbook approach is to leave todos alone and only change how you display it.
Add another filter state to TodoApp.
const [filter, setFilter] = useState('all'); // 'all' | 'active' | 'completed'Compute the list to display from todos and filter.
const filteredTodos = todos.filter(todo => {
if (filter === 'active') return !todo.completed;
if (filter === 'completed') return todo.completed;
return true;
});Once again, the principle is don’t make derived values into state. filteredTodos recomputes on every render, but at the scale of a Todo app the cost is negligible.
TodoFilter component #
Let’s pull the filter button group into its own component.
src/TodoFilter.jsx:
const FILTERS = [
{ value: 'all', label: 'All' },
{ value: 'active', label: 'Active' },
{ value: 'completed', label: 'Completed' },
];
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;Key patterns:
- Filter options live in a data array (
FILTERS) rendered withmap(#8) — adding/changing options doesn’t require touching JSX - The currently selected filter is visually distinguished (background/foreground inverted)
- Changes flow up to the parent through the
onChangecallback
Bulk-delete button on TodoStats #
Show the “Clear completed” button only when there’s at least one completed item. Conditional rendering (#7) fits naturally.
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 {total} , Remaining {remaining} , Completed {completed}</span>
{completed > 0 && (
<button onClick={onClearCompleted} style={{ fontSize: '12px' }}>
Clear completed
</button>
)}
</div>
);
}
export default TodoStats;completed > 0 && ... — remember the gotcha from #7? Because the left side is an explicit boolean comparison, it’s safe (writing completed && ... would render a 0 on the screen when the count is 0).
Putting it together in TodoApp #
Now let’s wire all the pieces.
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;There’s one intentional decision here.
TodoStatsreceives the fulltodos(because the stats should reflect the full picture, regardless of the filter)TodoListreceivesfilteredTodos(only what matches the current filter)
So two components are getting different processed views of the same todos state. That’s possible because the source of truth lives in TodoApp, and the children just render what they receive.
TodoList — refining the empty state #
So far TodoList has shown only one “No todos” message. But when a filter is active, the empty state means “nothing matches the filter,” not “no todos at all.” Let’s branch the message.
src/TodoList.jsx:
import TodoItem from './TodoItem';
const FILTER_LABEL = {
all: 'todos',
active: 'active items',
completed: 'completed items',
};
function TodoList({ todos, filter, totalCount, onToggle, onDelete }) {
if (todos.length === 0) {
if (totalCount === 0) {
return <p style={{ color: '#888' }}>No todos yet. Add a new one.</p>;
}
return <p style={{ color: '#888' }}>No {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;- Total count (
totalCount) is 0 → “No todos yet” - Total exists, but the filtered result is 0 → “No XXX”
A small difference, but the user experience clearly improves. Empty states are part of design too — that mindset matters in real-world development.
Verify it works #
Save and try the following.
- Add a few todos and complete some of them
- Click “Active” → only active items show
- Click “Completed” → only completed items show
- Go back to “All”
- Click “Clear completed” → completed items disappear at once
- Delete every todo → “No todos yet” message
- Set every todo to active and click “Completed” → “No completed items” message
A note on stats placement #
The stats and the bulk-delete button currently live together inside TodoStats. Other choices were possible:
- Split stats and bulk-delete into separate components
- Put the stats on top and the bulk-delete below the list
There’s no right answer. In this series we chose to keep related information close. It’s natural for users to see “you have N completed” right next to a “Clear them all at once” button.
When you’re making design decisions, instead of looking for the one correct answer, “can you explain why you chose this in one sentence?” is a good enough bar. If a different choice looks better later, swap it then.
?filter=active), the filter survives a refresh and you can share the same view via URL. You could do this with useSearchParams from #15, but in this series we kept it as in-memory state for simplicity. For a larger app with routing, the URL approach can be better.Wrap-up #
This post added filtering and a bulk operation.
- The filter is a separate state, and the displayed list is computed from
todosandfilter - From the same data, stats get the full set and the list gets the filtered set — children receive different views
- Empty states branch by situation
- The pattern of putting filter options in a data array and rendering with
map
So far the app supports adding, toggling, deleting, and filtering. But there’s no way to edit text once entered. In the next post, “Build a Todo App with React #4 Editing,” we’ll let you double-click an item to enter inline edit mode, save with Enter, and cancel with Escape. Along the way, useRef makes its first appearance.