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

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 tests

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.

// 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 project

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.

// 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 here

Common Pitfalls (and the fix)

Pro Tips

📋 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!

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

Related lessons