Build a Todo App with React #3: Filtering

6 min read

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.

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

Compute the list to display from todos and filter.

computing the filtered list
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:

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 with map (#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 onChange callback

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:

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:

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.

  • TodoStats receives the full todos (because the stats should reflect the full picture, regardless of the filter)
  • TodoList receives filteredTodos (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:

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.

  1. Add a few todos and complete some of them
  2. Click “Active” → only active items show
  3. Click “Completed” → only completed items show
  4. Go back to “All”
  5. Click “Clear completed” → completed items disappear at once
  6. Delete every todo → “No todos yet” message
  7. 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.

Tip
If you reflect the filter in the URL query (?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 todos and filter
  • 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.

X