Skip to main content
    Courses/React/Building a Complete React App

    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

    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.

    Try it Yourself »
    JavaScript
    // 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.

    Try it Yourself »
    JavaScript
    // 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.

    Try it Yourself »
    JavaScript
    // 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.

    Try it Yourself »
    JavaScript
    // 🎯 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.

    Try it Yourself »
    JavaScript
    // 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.

    Try it Yourself »
    JavaScript
    // 🎯 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.

    Try it Yourself »
    JavaScript
    // 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.

    Try it Yourself »
    JavaScript
    // 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.

    Try it Yourself »
    JavaScript
    // 🎯 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) 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

    TaskCodeReact concept
    Hold the listconst [todos, setTodos] = useState([])State
    Render rowstodos.map(t => <li key={t.id}>…</li>)Lists & keys
    AddsetTodos(p => [...p, newTodo])Immutable add
    Togglep.map(t => t.id===id ? {...t, completed:!t.completed} : t)Immutable update
    Deletep.filter(t => t.id !== id)Immutable remove
    Filter viewtodos.filter(t => …)Derived state
    Controlled inputvalue={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.

    Sign up for free to track which lessons you've completed and get learning reminders.

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service