Project
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.
Learn Python, JavaScript, Java and more with free interactive lessons, real projects and AI-powered help. Beginner-friendly.
Part of the free React course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
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.
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 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.
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() .
The controlled‑form component that drives it:
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.
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 ( {' '} ); every other item is returned untouched. The result is a new array, so React re‑renders.
Wired into the app and the row's click handler:
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.
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 .
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.
Putting the filter buttons and the derived list together in the app:
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.
📋 Quick Reference — What You Used
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.
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.
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)
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
The updater form reads the latest state, avoiding stale-state bugs in rapid updates.