Skip to content
React projects 5 min read

Project: To-Do App

The to-do app is the canonical first React project for good reason: it exercises every fundamental in one small, self-contained codebase. You will compose components, lift and update state, handle events from a controlled input, render a list with stable keys, derive a filtered view, and persist data across reloads with localStorage. By the end you will have a fully working app and—more importantly—a clear mental model of how data flows through a React UI.

What we are building

A single-page app that lets you add tasks, toggle them complete, delete them, and filter between All, Active, and Completed views. Everything lives in component state, and the full list is mirrored to localStorage so your tasks survive a refresh. Scaffold a fresh project with Vite before you begin:

npm create vite@latest todo-app -- --template react
cd todo-app
npm install
npm run dev

Component breakdown

Rather than one giant component, we split the UI by responsibility. State that more than one component needs is lifted to the nearest common parent—here, App—and passed down as props. Each child stays focused and easy to test.

ComponentResponsibilityOwns state?
AppHolds the todos array and filter, runs persistenceYes
TodoFormControlled input for adding a taskLocal (text)
TodoListMaps todos to rowsNo
TodoItemRenders one todo, fires toggle/deleteNo
FiltersSwitches the active filterNo

The App component and its state

App is the source of truth. We initialize todos lazily from localStorage by passing a function to useState, so the parse runs only on the first render rather than on every one. A useEffect then writes the array back to storage whenever it changes.

import { useState, useEffect } from "react";
import TodoForm from "./TodoForm";
import TodoList from "./TodoList";
import Filters from "./Filters";

const STORAGE_KEY = "todos.v1";

export default function App() {
  const [todos, setTodos] = useState(() => {
    const saved = localStorage.getItem(STORAGE_KEY);
    return saved ? JSON.parse(saved) : [];
  });
  const [filter, setFilter] = useState("all");

  useEffect(() => {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
  }, [todos]);

  function addTodo(text) {
    setTodos((prev) => [
      ...prev,
      { id: crypto.randomUUID(), text, done: false },
    ]);
  }

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

  function deleteTodo(id) {
    setTodos((prev) => prev.filter((t) => t.id !== id));
  }

  const visible = todos.filter((t) =>
    filter === "active" ? !t.done : filter === "completed" ? t.done : true
  );

  return (
    <main>
      <h1>To-Do</h1>
      <TodoForm onAdd={addTodo} />
      <Filters filter={filter} onChange={setFilter} />
      <TodoList todos={visible} onToggle={toggleTodo} onDelete={deleteTodo} />
      <p>{todos.filter((t) => !t.done).length} item(s) left</p>
    </main>
  );
}

Every updater uses the functional form (prev => ...) and returns a brand-new array or object. React detects changes by reference, so mutating the existing array in place would not trigger a re-render—and would break the persistence effect too.

Gotcha: never use a list item’s array index as its React key. When you delete or reorder todos the indices shift, so React reuses the wrong DOM nodes and component state. A stable id from crypto.randomUUID() (or Date.now()) keeps every row correctly identified.

A controlled form for adding tasks

TodoForm owns only its own input text. On submit it calls the onAdd callback from props and clears the field. Binding value to state and updating it in onChange makes this a controlled input—React is the single source of truth for what is typed.

import { useState } from "react";

export default function TodoForm({ onAdd }) {
  const [text, setText] = useState("");

  function handleSubmit(e) {
    e.preventDefault();
    const trimmed = text.trim();
    if (!trimmed) return;
    onAdd(trimmed);
    setText("");
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="What needs doing?"
        aria-label="New task"
      />
      <button type="submit">Add</button>
    </form>
  );
}

Wrapping the input in a <form> and calling e.preventDefault() means the Enter key submits naturally without reloading the page.

Rendering the list

TodoList is a pure presentational component: it receives the already-filtered array and turns each entry into a TodoItem. The key goes on the outermost element returned by the map.

import TodoItem from "./TodoItem";

export default function TodoList({ todos, onToggle, onDelete }) {
  if (todos.length === 0) return <p>Nothing here yet.</p>;

  return (
    <ul>
      {todos.map((todo) => (
        <TodoItem
          key={todo.id}
          todo={todo}
          onToggle={onToggle}
          onDelete={onDelete}
        />
      ))}
    </ul>
  );
}
export default function TodoItem({ todo, onToggle, onDelete }) {
  return (
    <li>
      <label style={{ textDecoration: todo.done ? "line-through" : "none" }}>
        <input
          type="checkbox"
          checked={todo.done}
          onChange={() => onToggle(todo.id)}
        />
        {todo.text}
      </label>
      <button onClick={() => onDelete(todo.id)} aria-label="Delete task">

      </button>
    </li>
  );
}

The filter bar

Filters is stateless. It reflects the active filter from props and reports clicks back up. Deriving the visible list in App (rather than storing a second filtered array) keeps a single source of truth and avoids state that can fall out of sync.

const OPTIONS = ["all", "active", "completed"];

export default function Filters({ filter, onChange }) {
  return (
    <div role="group" aria-label="Filter tasks">
      {OPTIONS.map((opt) => (
        <button
          key={opt}
          onClick={() => onChange(opt)}
          aria-pressed={filter === opt}
        >
          {opt}
        </button>
      ))}
    </div>
  );
}

Persistence in action

Because the effect runs after every todos change, the stored value always matches the screen. Add a task, refresh the browser, and it is still there:

Output:

localStorage["todos.v1"]
[{"id":"a1f3...","text":"Buy milk","done":false},
 {"id":"b8c2...","text":"Ship feature","done":true}]

Best practices

  • Keep shared state in the closest common parent and pass data down through props.
  • Treat state as immutable: build new arrays/objects with spread, map, and filter instead of mutating in place.
  • Use a stable, unique key for list items—never the array index.
  • Derive values like the filtered list and “items left” during render rather than storing them as extra state.
  • Initialize state from localStorage lazily, and sync writes with a single useEffect keyed on the data.
  • Make inputs controlled and validate (e.g. trim and reject empty) before committing to state.
Last updated June 14, 2026
Was this helpful?