Build a Todo App with React #2: Completion Toggle and Stats

6 min read

Last time we built the skeleton of a Todo app that supports add and delete. This time we’ll add a checkbox to mark each item as completed, plus a stats area showing remaining / total counts.

Goals for this step #

  • Click the checkbox next to an item → toggle complete/incomplete
  • Completed items get a visual distinction (gray, strikethrough)
  • Somewhere on the screen, display “Total N / Remaining M”

Add the toggle handler #

The data flow is the same as the previous post. State lives in TodoApp, and changes flow up to the parent through callbacks. Add a toggle handler to 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;

The newly added core:

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

This pattern shows up often, so it’s worth memorizing.

  • Build a new array with map
  • Replace the matching item with a new object ({ ...todo, completed: !todo.completed })
  • Leave the rest as is

This is the standard form of the immutable-update pattern from #5. Never directly mutate like todo.completed = !todo.completed — it’s the same object reference, so React won’t detect the change.

Add a checkbox to TodoItem #

Add a checkbox to TodoItem and visually distinguish the completed state.

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)}>
        Delete
      </button>
    </li>
  );
}

export default TodoItem;
  • The pair of checked={todo.completed} and onChange makes the checkbox a controlled component (#9)
  • Completed items are distinguished with opacity: 0.5 (semi-transparent) and text-decoration: line-through (strikethrough)
  • A common pattern of branching inline styles based on a condition

Pass the handler down through TodoList #

TodoList simply passes the received onToggle down to its children.

src/TodoList.jsx:

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

function TodoList({ todos, onToggle, onDelete }) {
  if (todos.length === 0) {
    return <p style={{ color: '#888' }}>No todos yet. Add a new one.</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;

You can see how an intermediate component just passes props it doesn’t itself care about down the tree — that’s exactly the prop drilling from #12. At the scale of a Todo app it isn’t really a problem (the tree is shallow and there are few components). When the app grows, that’s when you bring in Context.

TodoStats — the stats component #

A simple component that shows the total count and the remaining count. It just receives data, calculates, and displays.

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 {total} , Remaining {remaining} , Completed {completed}
    </div>
  );
}

export default TodoStats;

One thing to call out — total, remaining, and completed are not stored as state. They can be computed on the fly from todos. This is the Single Source of Truth principle from #11.

Don’t make computable values into state. The only real state is todos; the stats are derived values.

If we made total a separate state, every time todos changed we’d need an effect to keep them in sync, opening the door to sync bugs. Just computing it on each render is simpler and safer.

Do we need useMemo? #

You might recall useMemo from #14. “Isn’t running filter on every render inefficient?”

The answer: at the current scale, you don’t need to worry. There won’t be tens of thousands of items but tens to hundreds, and filter is a very fast operation. Apply the principle from #14: measure first, optimize only when something is actually slow. Right now, keeping the code simple is far more valuable.

Verify it works #

Save the file and check the following in the browser.

  1. Add a few todos
  2. Clicking a checkbox dims the item and adds a strikethrough
  3. Clicking again returns it to normal
  4. The “Remaining / Completed” numbers in the stats area update immediately
  5. Deleting an item updates the stats automatically

Common mistakes #

1. Mutating an object directly #

bad example
function toggleTodo(id) {
  const todo = todos.find(t => t.id === id);
  todo.completed = !todo.completed;  // 🚫
  setTodos([...todos]);
}

Don’t mutate directly with todo.completed = .... Because it’s the same object reference, React’s comparison may treat it as “the same object”, and future tools like the React Compiler also assume immutability. Always stick to creating a new object ({ ...todo, completed: ... }).

2. Using value on a checkbox #

bad example
<input type="checkbox" value={todo.completed} onChange={...} />

A checkbox uses checked, not value (#9). value is just the value sent on form submit and has nothing to do with whether it’s checked.

3. Toggling by index #

bad example
{todos.map((todo, index) => (
  <TodoItem onToggle={() => toggleByIndex(index)} ... />
))}

Toggling by ID is safer. With an index, applying sorting or filtering shifts the index out of sync with the actual item. The ID is attached to the item itself, so it’s accurate through any transformation.

Wrap-up #

This post added two things.

  • Completion toggle — checkbox + visual distinction + the map pattern for immutable updates
  • Stats — derived values aren’t state; just compute them

Right now our app always shows every todo at once. As todos grow, you start wanting to “see only what’s left” or “see only what’s done”. In the next post, “Build a Todo App with React #3 Filtering,” we’ll add All / Active / Completed filters and bulk operations like deleting all completed items.

X