Building a Complete React App
Reviewed & published by Brayan K
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.
Part of the free React course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
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
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.
// 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 list like any array:
console.log("Total todos:", todos.length); // Total todos: 3
// Loop over it (this is what the UI will do for each row):
todos.forEach(todo => {
const box = todo.completed ? "[x]" : "[ ]";
console.log(box + " " + todo.text);
});
// [ ] Learn props
// [x] Learn useState
// [ ] Build a project
// 👉 In React, this array lives in state:
// const [todos, setTodos] = useState(initialTodos);
// Every feature below is just a different way of producing a NEW array.Here's the component shell that holds that array. Read it — you'll fill in each commented slot over the next milestones.
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.
// 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(rows.join("\n"));
// [ ] Learn props
// [x] Learn useState
// [ ] Build a project
// Empty-state check (every list UI needs this):
const empty = [];
console.log(empty.length === 0 ? "Nothing to do!" : "has items");
// Nothing to do!In JSX, that same .map() returns elements instead of strings:
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().
// 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, "Write tests");
todos = addTodo(todos, "Ship it");
console.log("Count:", todos.length); // Count: 3
todos.forEach(t => console.log("-", t.text));
// - Learn props
// - Write tests
// - Ship it
// ❌ todos.push(newTodo) -> mutates the old array; React won't re-render.
// ✅ return [...list, newTodo] -> new reference; React re-renders.The controlled‑form component that drives it:
🎯 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 — 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));
// ✅ Expected output:
// Learn props
// Write tests4️⃣ 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.
// 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(todos, 1); // uncheck #1 again
todos.forEach(t =>
console.log((t.completed ? "[x] " : "[ ] ") + t.text)
);
// [ ] Learn props
// [x] Build a project
// The trick: { ...todo, completed: !todo.completed }
// makes a NEW object with one field changed. State stays immutable.Wired into the app and the row's click handler:
🎯 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 — 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 // others unchanged
);
}
todos = toggleTodo(todos, 2); // check #2 only
todos.forEach(t =>
console.log((t.completed ? "[x] " : "[ ] ") + t.text)
);
// ✅ Expected output:
// [ ] Learn props
// [x] Build a project5️⃣ 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.
// 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.forEach(t => console.log("-", t.text));
// - Learn props
// - Ship it
// filter() never mutates — it returns a fresh array, perfect for setState.In the app, with a delete button on each row:
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.
// 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);
console.log(name.toUpperCase() + ":", rows.join(", ") || "(none)");
};
show("all"); // ALL: Learn props, Build a project, Ship it
show("active"); // ACTIVE: Build a project, Ship it
show("done"); // DONE: Learn props
// Why compute instead of store? One source of truth (todos) can't drift
// out of sync with a separate "filtered" copy. Derive, don't duplicate.Putting the filter buttons and the derived list together in the app:
🎯 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 — 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 activeCount)
// Then run clearCompleted and log the new count.
//
// ✅ Expected (for the starter list below): "2 items left",
// and after clearing completed, 2 todos remain.
const todos = [
{ id: 1, text: "Learn props", completed: true },
{ id: 2, text: "Build a project", completed: false },
{ id: 3, text: "Ship it", completed: false },
];
// your code hereCommon Pitfalls (and the fix)
- Mutating state directly: todos.push(x) or todo.completed = true edits 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 filteredTodos in state lets it drift out of sync. Compute it during render from todos + filter.
- Reading stale state in rapid updates: when the next value depends on the previous, use the updater form: setTodos(prev => [...prev, x]), not setTodos([...todos, x]).
Pro Tips
- 💡 One source of truth: keep the raw todos in state and derive everything else (visible list, counts) during render.
- 💡 Lift state up: todos lives 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 useEffect that writes todos to localStorage makes 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() + stable keys 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 a useTodos custom hook, then try drag‑to‑reorder or syncing to a real API. Then ship it — deploy free to Vercel or Netlify.
Practice quiz
How is each todo's data shaped in this project?
- A string of text
- A pair of arrays
- An object with id, text, and completed
- A Map keyed by text
Answer: An object with id, text, and completed. Each todo is a plain object { id, text, completed }, and the whole list is an array of them.
Why use a stable id (not the array index) as the React key?
- Indexes shift on add/delete/reorder, so React can attach a row's state to the wrong data
- Indexes are slower to compute
- Keys must be numbers
- It avoids prop drilling
Answer: Indexes shift on add/delete/reorder, so React can attach a row's state to the wrong data. A stable, unique id always identifies the same item; indexes change when the list changes.
Which adds a todo immutably?
- todos.push(newTodo)
- todos[todos.length] = newTodo
- list.append(newTodo)
- return [...list, newTodo]
Answer: return [...list, newTodo]. Spreading the old items into a new array gives a fresh reference so React re-renders; push() mutates.
How do you toggle one todo's completed flag immutably?
- todo.completed = !todo.completed
- list.map(t => t.id === id ? { ...t, completed: !t.completed } : t)
- list.filter(t => t.completed)
- splice the item out and back in
Answer: list.map(t => t.id === id ? { ...t, completed: !t.completed } : t). map returns a copy for the matching id with completed flipped, leaving the others untouched.
Which array method deletes a todo by id immutably?
- .filter()
- .map()
- .forEach()
- .splice()
Answer: .filter(). filter() keeps every todo whose id is not the removed one, returning a fresh, shorter array.
How should the All/Active/Done filtered list be handled?
- Stored as a second array in state
- Saved to localStorage
- Computed (derived) during render from todos + filter
- Mutated in place
Answer: Computed (derived) during render from todos + filter. Store only the filter value and derive the visible list each render — one source of truth, no drift.
Why must a controlled form's submit handler call e.preventDefault()?
- To clear the input
- To stop the page from reloading and wiping state
- To validate the form
- To trigger a re-render
Answer: To stop the page from reloading and wiping state. A form submit reloads the page by default; preventDefault stops it so your state survives.
Where should the todos state live?
- In each child component
- In a global variable
- In localStorage only
- In the closest common parent (lifting state up)
Answer: In the closest common parent (lifting state up). Lift state up to the common parent; children get it via props and request changes through callbacks.
Why won't React re-render after todos.push(x)?
- push is asynchronous
- The array reference is unchanged, so React sees no change
- push throws an error
- You need to call useEffect
Answer: The array reference is unchanged, so React sees no change. React compares state by reference; mutating the same array keeps the reference identical.
When the next state depends on the previous, you should…
- Read the current state variable directly
- Call setState twice
- Use the updater form: setTodos(prev => [...prev, x])
- Avoid setState entirely
Answer: Use the updater form: setTodos(prev => [...prev, x]). The updater form reads the latest state, avoiding stale-state bugs in rapid updates.
Continue this course
- Previous: Checkpoint: Patterns & Modern Hooks
- Next: useFormStatus & Form State — Read a parent form's pending state from a child with the React 19 useFormStatus hook
- Quick reference: React cheat sheet