Lesson 8 • Capstone Project
Building a Complete React App ⚛️
This is the capstone. You'll combine components, props, useState, events, forms, and lists into one real, working app: a Todo List. You'll build it in six small milestones — add items, check them off, delete them, and filter the view — using the same immutable‑update pattern professionals use every day.
What You'll Build
- A Todo app from scratch out of small components
- State shaped as an array of { id, text, completed } objects
- A controlled form that adds new todos
- A click‑to‑toggle 'completed' feature
- Per‑item delete with a fresh, filtered array
- All / Active / Done filtering as derived state
npm create vite@latest todo -- --template react.1️⃣ Scaffold & State Shape
Before any UI, decide what the data looks like. A todo list is simply an array of objects, each with an id (unique, so React can tell rows apart), a text, and a completed flag. That array lives in one place — useState in your top component — and is the single source of truth. Run the logic first, then read the component shell.
Try It: the todo data model
Run the array of todos that powers the whole app.
// MILESTONE 1 — the data behind a Todo app
// A "todo" is just a plain object. The whole app is an ARRAY of them.
// Each todo needs three things:
// id -> a unique number so React can tell rows apart
// text -> what the user typed
// completed -> true / false, starts false
const todos = [
{ id: 1, text: "Learn props", completed: false },
{ id: 2, text: "Learn useState", completed: true },
{ id: 3, text: "Build a project", completed: false },
];
// You can read the
...Here's the component shell that holds that array. Read it — you'll fill in each commented slot over the next milestones.
// App.jsx — the component shell (READ-ONLY, this is JSX)
import { useState } from "react";
// One todo looks like: { id, text, completed }
const initialTodos = [
{ id: 1, text: "Learn props", completed: false },
{ id: 2, text: "Learn useState", completed: true },
];
export default function App() {
// The single source of truth for the whole app:
const [todos, setTodos] = useState(initialTodos);
return (
<div className="app">
<h1>My Todos ({todos.length})</h1>
{/* TodoForm goes here (Milestone 3) */}
{/* TodoList goes here (Milestone 2) */}
{/* Filter buttons here (Milestone 6) */}
</div>
);
}2️⃣ Render the List
To show the todos you call .map() on the array and return one <li> per item. Each element needs a key — a stable, unique value (use todo.id, never the array index) so React can update the right row. Always handle the empty state too, or new users see a blank screen.
Try It: map data to rows
See how .map() turns the array into rows, plus the empty‑state check.
// MILESTONE 2 (logic) — .map() turns DATA into ROWS
// In React you return JSX from .map(); here we build text rows so it runs.
const todos = [
{ id: 1, text: "Learn props", completed: false },
{ id: 2, text: "Learn useState", completed: true },
{ id: 3, text: "Build a project", completed: false },
];
// .map() makes a NEW array — one item out for each item in.
const rows = todos.map(todo => {
const box = todo.completed ? "[x]" : "[ ]";
return box + " " + todo.text;
});
console.log(
...In JSX, that same .map() returns elements instead of strings:
// TodoList.jsx — turn the array into rows (READ-ONLY JSX)
// Props: { todos } — the array passed down from App.
export default function TodoList({ todos }) {
// Empty state: always handle "no data yet".
if (todos.length === 0) {
return <p className="empty">Nothing to do. Add a todo above!</p>;
}
return (
<ul className="todo-list">
{todos.map(todo => (
// key={todo.id} lets React match each row to its data.
// Use the stable id — NEVER the array index.
<li key={todo.id} className={todo.completed ? "done" : ""}>
{todo.text}
</li>
))}
</ul>
);
}
// Used from App like this:
// <TodoList todos={todos} />3️⃣ Add‑Item Form
The form is a controlled input: its value lives in state and updates on every onChange. On submit you call e.preventDefault() (so the page doesn't reload), ignore empty text, and hand the new value up to the parent via an onAdd prop. The parent adds it immutably — spread the old array and append a new object, never push().
Try It: add a todo immutably
Build a NEW array with the spread operator instead of mutating.
// MILESTONE 3 (logic) — adding immutably
// RULE: never push() into state. Build a NEW array with the new item.
let todos = [
{ id: 1, text: "Learn props", completed: false },
];
function addTodo(list, text) {
const newTodo = {
id: Date.now(), // simple unique id (ms timestamp)
text: text,
completed: false, // new todos start unchecked
};
// Spread the old items, then append the new one -> brand new array.
return [...list, newTodo];
}
todos = addTodo(todos, "Wr
...The controlled‑form component that drives it:
// TodoForm.jsx — a controlled form to add a todo (READ-ONLY JSX)
import { useState } from "react";
// Props: { onAdd } — a function App gives us to add a new todo.
export default function TodoForm({ onAdd }) {
// The input is "controlled": its value lives in state.
const [text, setText] = useState("");
function handleSubmit(e) {
e.preventDefault(); // stop the page from reloading
const trimmed = text.trim();
if (trimmed === "") return; // ignore empty submissions
onAdd(trimmed); // tell App to add it
setText(""); // clear the box for the next one
}
return (
<form onSubmit={handleSubmit}>
<input
value={text}
onChange={e => setText(e.target.value)}
placeholder="What needs doing?"
/>
<button type="submit">Add</button>
</form>
);
}🎯 Your Turn: Finish the Add Function
Fill in the three ___ blanks so addTodo returns a brand‑new array with the new item appended. Run it and check your output matches the expected lines.
🎯 Your turn: add a todo
Replace each ___ using the 👉 hints, then run it.
// 🎯 YOUR TURN — finish the add function (fill each ___)
let todos = [
{ id: 1, text: "Learn props", completed: false },
];
function addTodo(list, text) {
const newTodo = {
id: Date.now(),
text: text,
completed: ___, // 👉 new todos start unchecked: use false
};
// 👉 return a NEW array: the old items, then newTodo
return [___, ___]; // 👉 use the spread ...list, then newTodo
}
todos = addTodo(todos, "Write tests");
todos.forEach(t => console.log(t.text));
...4️⃣ Toggle Complete
Checking a todo off means flipping its completed flag — but only for the one you clicked. You .map() over the array and, for the matching id, return a copy with completed negated ({ ...todo, completed: !todo.completed }); every other item is returned untouched. The result is a new array, so React re‑renders.
Try It: toggle one item
Flip completed on the matching id, copy the rest.
// MILESTONE 4 (logic) — toggle one item, copy the rest
let todos = [
{ id: 1, text: "Learn props", completed: false },
{ id: 2, text: "Build a project", completed: false },
];
function toggleTodo(list, id) {
return list.map(todo =>
// Only the matching id changes; everyone else is returned untouched.
todo.id === id ? { ...todo, completed: !todo.completed } : todo
);
}
todos = toggleTodo(todos, 1); // check #1
todos = toggleTodo(todos, 2); // check #2
todos = toggleTodo(tod
...Wired into the app and the row's click handler:
// In App.jsx — the handler you pass down (READ-ONLY JSX)
function toggleTodo(id) {
setTodos(prev =>
prev.map(todo =>
// Found the one clicked? Return a COPY with completed flipped.
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
}
// In TodoList.jsx — wire a click to it:
// <li onClick={() => onToggle(todo.id)}>
// <input type="checkbox" checked={todo.completed} readOnly />
// {todo.text}
// </li>🎯 Your Turn: Finish the Toggle
Fill in the blanks so only the clicked todo flips its completed flag and the rest stay the same. Run it and compare with the expected output.
🎯 Your turn: toggle complete
Replace each ___ using the 👉 hints, then run it.
// 🎯 YOUR TURN — finish the toggle (fill each ___)
let todos = [
{ id: 1, text: "Learn props", completed: false },
{ id: 2, text: "Build a project", completed: false },
];
function toggleTodo(list, id) {
return list.map(todo =>
// 👉 if this is the clicked id, return a COPY with completed flipped
todo.id === ___ // 👉 compare to id
? { ...todo, completed: ___ } // 👉 use !todo.completed
: todo
...5️⃣ Delete a Todo
Deleting is the cleanest operation: .filter() keeps every todo whose id is not the one you're removing, producing a shorter new array. Like .map(), filter() never mutates the original — it's purpose‑built for setState.
Try It: delete by id
filter() keeps everyone except the removed id.
// MILESTONE 5 (logic) — delete = filter OUT one item
let todos = [
{ id: 1, text: "Learn props", completed: false },
{ id: 2, text: "Build a project", completed: false },
{ id: 3, text: "Ship it", completed: true },
];
function deleteTodo(list, id) {
// Keep everyone EXCEPT the matching id -> new, shorter array.
return list.filter(todo => todo.id !== id);
}
todos = deleteTodo(todos, 2); // remove "Build a project"
console.log("Remaining:", todos.length); // Remaining: 2
todos.
...In the app, with a delete button on each row:
// In App.jsx — delete by id (READ-ONLY JSX)
function deleteTodo(id) {
// filter() keeps every todo whose id is NOT the one we removed.
setTodos(prev => prev.filter(todo => todo.id !== id));
}
// In TodoList.jsx — a delete button per row:
// <button onClick={() => onDelete(todo.id)} aria-label="Delete">
// ×
// </button>6️⃣ Filter: All / Active / Done
The final feature is the most important idea in React: derived state. Don't keep a second "filtered" array in state — it can drift out of sync. Instead, store one small value (filter) and compute the visible list on every render with .filter(). One source of truth, zero bugs.
Try It: derive the visible list
Compute all/active/done from one source of truth.
// MILESTONE 6 (logic) — filtering is DERIVED state
const todos = [
{ id: 1, text: "Learn props", completed: true },
{ id: 2, text: "Build a project", completed: false },
{ id: 3, text: "Ship it", completed: false },
];
function getVisible(list, filter) {
if (filter === "active") return list.filter(t => !t.completed);
if (filter === "done") return list.filter(t => t.completed);
return list; // "all"
}
const show = name => {
const rows = getVisible(todos, name).map(t => t.text)
...Putting the filter buttons and the derived list together in the app:
// In App.jsx — derive the visible list from state (READ-ONLY JSX)
const [filter, setFilter] = useState("all"); // "all" | "active" | "done"
// Don't store a second list — COMPUTE it on each render:
const visible = todos.filter(todo => {
if (filter === "active") return !todo.completed;
if (filter === "done") return todo.completed;
return true; // "all"
});
return (
<>
<TodoForm onAdd={addTodo} />
<div className="filters">
<button onClick={() => setFilter("all")}>All</button>
<button onClick={() => setFilter("active")}>Active</button>
<button onClick={() => setFilter("done")}>Done</button>
</div>
{/* Pass the DERIVED list, not the raw one: */}
<TodoList todos={visible} onToggle={toggleTodo} onDelete={deleteTodo} />
</>
);🎯 Stretch Challenge: Items‑Left Counter
No blanks this time — just a brief and an outline. Add the two things every real todo app has: an "X items left" counter and a "Clear completed" action. Build it from the comment outline, run it, and check your result against the expected output in the comments.
🎯 Stretch challenge: counter + clear completed
Follow the outline, write the logic yourself, then run it.
// 🎯 STRETCH CHALLENGE — add an "active items left" counter
// and a "Clear completed" button to your Todo app.
//
// You have: todos = [{ id, text, completed }, ...]
//
// 1. activeCount: how many todos are NOT completed?
// -> filter the list, then read .length
//
// 2. clearCompleted(list): return a new array with the
// completed ones removed.
// -> filter to keep only todos where completed is false
//
// 3. Log a footer line like real todo apps:
// "2 items left" (use a
...Common Pitfalls (and the fix)
- Mutating state directly:
todos.push(x)ortodo.completed = trueedits the old array, so React sees the same reference and won't re‑render. Always return a new array/object ([...todos, x],{ ...todo, completed: true }). - Using the array index as
key:key={i}breaks when you add, delete, or reorder — React reuses the wrong rows. Use a stable id (key={todo.id}). - Forgetting
e.preventDefault(): a<form>submit reloads the page and wipes your state. Call it first in the submit handler. - Storing derived data in state: keeping a separate
filteredTodosin state lets it drift out of sync. Compute it during render fromtodos+filter. - Reading stale state in rapid updates: when the next value depends on the previous, use the updater form:
setTodos(prev => [...prev, x]), notsetTodos([...todos, x]).
Pro Tips
- 💡 One source of truth: keep the raw
todosin state and derive everything else (visible list, counts) during render. - 💡 Lift state up:
todoslives in the parent; children get data via props and report changes via callbacks (onAdd,onToggle,onDelete). - 💡 Small components:
TodoForm,TodoList, and a row component each do one job — easier to read and reuse. - 💡 Persist later: a tiny
useEffectthat writestodostolocalStoragemakes the list survive a refresh.
📋 Quick Reference — What You Used
| Task | Code | React concept |
|---|---|---|
| Hold the list | const [todos, setTodos] = useState([]) | State |
| Render rows | todos.map(t => <li key={t.id}>…</li>) | Lists & keys |
| Add | setTodos(p => [...p, newTodo]) | Immutable add |
| Toggle | p.map(t => t.id===id ? {...t, completed:!t.completed} : t) | Immutable update |
| Delete | p.filter(t => t.id !== id) | Immutable remove |
| Filter view | todos.filter(t => …) | Derived state |
| Controlled input | value={text} onChange={e=>setText(e.target.value)} | Forms |
| Pass behaviour down | <TodoForm onAdd={addTodo} /> | Props & events |
Frequently Asked Questions
Q: Why can't I just push() a new todo into the array?
push() changes the existing array in place. React compares the old and new state by reference; since the reference is the same, it thinks nothing changed and skips the re‑render. Returning a new array ([...todos, x]) gives a fresh reference, so the UI updates.
Q: Where should the todos state live?
In the closest common parent of everything that needs it — usually the top App component. This is "lifting state up." Children receive todos as props and request changes through callback props.
Q: Why todo.id for the key and not the index?
Indexes shift when you add, delete, or reorder items, so React can attach a row's state to the wrong data. A stable, unique id always identifies the same item.
Q: Should I store the filtered list in state?
No. Store only the filter value and compute the visible list during render. Derived data in state is a classic source of "the UI is out of sync" bugs.
Q: How do I make the list survive a page refresh?
Add a useEffect that saves todos to localStorage whenever it changes, and read it back as the initial state. That's a great next step once the basics work.
🎉 Project Complete!
- ✅ Modelled state as an array of
{ id, text, completed }objects - ✅ Rendered the list with
.map()+ stablekeys and an empty state - ✅ Added todos immutably with a controlled form and the spread operator
- ✅ Toggled completion by copying one object inside
.map() - ✅ Deleted items with
.filter()— a fresh, shorter array - ✅ Filtered the view with derived state — one source of truth
- Where to go next: persist with
localStorage, add editing of existing todos, extract auseTodoscustom hook, then try drag‑to‑reorder or syncing to a real API. Then ship it — deploy free to Vercel or Netlify.
Sign up for free to track which lessons you've completed and get learning reminders.