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.
| Component | Responsibility | Owns state? |
|---|---|---|
App | Holds the todos array and filter, runs persistence | Yes |
TodoForm | Controlled input for adding a task | Local (text) |
TodoList | Maps todos to rows | No |
TodoItem | Renders one todo, fires toggle/delete | No |
Filters | Switches the active filter | No |
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 fromcrypto.randomUUID()(orDate.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, andfilterinstead of mutating in place. - Use a stable, unique
keyfor 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
localStoragelazily, and sync writes with a singleuseEffectkeyed on the data. - Make inputs controlled and validate (e.g. trim and reject empty) before committing to state.