Skip to main content

โœ… Weekend Project: Build a React To-Do App with CRUD

The to-do app is the "hello world" of real interfaces โ€” and for good reason. In one small project it exercises every core React idea from this week: components, props, state, events, list rendering with keys, controlled inputs, and effects. This weekend you'll build one properly โ€” add, list, toggle-complete, edit in place, delete, filter, and remember everything across reloads โ€” and walk away with the muscle memory that makes the rest of React feel easy.

Week 4 · Weekend Project · React Capstone

๐ŸŽฏ Learning Objectives

By completing this project, you will be able to:

  • Scaffold a React app with Vite and organize it into function components
  • Model application state with useState and update it immutably โ€” never mutating arrays or objects in place
  • Render a list with stable keys (a real id, not the array index) and explain why that matters
  • Build controlled inputs for both adding and editing items
  • Lift state up and pass data and callbacks down through props for full CRUD
  • Persist state to localStorage with useEffect so nothing is lost on refresh

Estimated Time: 4โ€“6 hours across the weekend

Project: A polished, persistent React to-do app with add / edit / toggle / delete / filter.

In This Project

The Goal

Build a single-page to-do app a real person would actually use: type a task and add it, tick items off, rename one, throw one away, and filter down to just what's left. Refresh the page and it's all still there. It should feel instant, because in React the screen is simply a function of your state โ€” change the state, and the UI re-renders itself.

The whole project rests on one mental model. A single source of truth โ€” the todos array โ€” lives in the top App component. Data flows down through props, and events flow up through callback props. Keep this component tree in your head; every stage below adds one branch of it.

graph TD A["App
owns todos + filter state"] --> B["TodoForm
controlled input โ†’ onAdd"] A --> C["TodoFilter
all / active / completed"] A --> D["TodoList
maps visible todos"] D --> E["TodoItem #1
toggle ยท edit ยท delete"] D --> F["TodoItem #2"] D --> G["TodoItem #n"]

Notice how thin the children are. App holds the data and the operations; everyone else renders what they're handed and shouts back up when the user clicks. That one-way flow is the whole philosophy of React.

๐Ÿ“– "The UI is a function of state"

In older DOM code you'd manually find an element and change it after every event. React flips that: you describe what the screen should look like for a given state, then only ever change the state โ€” React figures out the minimal DOM edits. Your job stops being "update the page" and becomes "update the data correctly," which is far easier to get right.

Prerequisites

This is the Week 4 capstone, so it pulls together the whole "React Fundamentals" week. Before you start, make sure you're comfortable with:

  • Function components & JSX โ€” writing a component that returns markup and rendering it
  • Props โ€” passing data and functions from a parent to a child
  • The useState hook โ€” reading state and calling its setter to trigger a re-render
  • Events โ€” onClick, onChange, onSubmit, and calling e.preventDefault()
  • Immutable array methods โ€” map, filter, and the spread operator [...arr] from Week 2
  • The useEffect hook โ€” running a side effect after render (fresh from this week's hooks lesson)

You'll need Node.js 18+ (which brings npm), a code editor, and a terminal. Check with node --version first. No prior Vite experience is required โ€” Stage 1 walks you through it.

Required Features Checklist

These are the non-negotiables โ€” the full CRUD lifecycle plus filtering and persistence. Everything here is achievable with useState, useEffect, and props; no extra libraries. Tick each off as you go.

โœ… Must-have features

  • โ˜ Add a to-do from a controlled text input (empty submissions ignored)
  • โ˜ List all to-dos, with a friendly empty state when there are none
  • โ˜ Toggle complete โ€” a checkbox that strikes the item through
  • โ˜ Edit an item's text in place and save it
  • โ˜ Delete an item
  • โ˜ Filter by All / Active / Completed
  • โ˜ Persist to localStorage so the list survives a refresh
  • โ˜ Function components + hooks only; state updated immutably; a stable key per item

Project Structure

Here's the layout you're building toward. One component per file, all under src/components/, with App.jsx as the single owner of state. Vite generates the rest of the scaffold for you in Stage 1.

react-todo/
โ”œโ”€โ”€ index.html            <-- Vite's entry HTML (has <div id="root">)
โ”œโ”€โ”€ package.json          <-- scripts + dependencies
โ”œโ”€โ”€ vite.config.js        <-- Vite + React plugin
โ””โ”€โ”€ src/
    โ”œโ”€โ”€ main.jsx          <-- mounts <App /> into #root
    โ”œโ”€โ”€ App.jsx           <-- owns todos + filter state, all CRUD handlers
    โ”œโ”€โ”€ App.css           <-- component styles
    โ””โ”€โ”€ components/
        โ”œโ”€โ”€ TodoForm.jsx  <-- controlled input to add a todo
        โ”œโ”€โ”€ TodoFilter.jsx<-- All / Active / Completed buttons
        โ”œโ”€โ”€ TodoList.jsx  <-- maps todos โ†’ TodoItem, handles empty state
        โ””โ”€โ”€ TodoItem.jsx  <-- one row: toggle, edit-in-place, delete

The golden rule that makes this app easy to reason about: state lives in exactly one place (App), and everything below it is a "dumb" component that renders props and reports events. When a bug appears, you always know where to look โ€” the data only changes in one file.

๐Ÿ’ก Why .jsx, not .js?

Files that contain JSX get the .jsx extension by modern convention, and Vite's React template expects it. It's not strictly required, but it signals intent and keeps tooling happy. (Create React App used .js everywhere; new Vite projects prefer .jsx.)

Stage 1 โ€” Scaffold with Vite

We'll use Vite to create the project. It gives you a near-instant dev server with hot module replacement and zero config to fuss over โ€” the modern default for a new React app. Run these in your terminal:

# Scaffold a React project (choose "React" then "JavaScript" if prompted)
npm create vite@latest react-todo -- --template react

# Install dependencies and start the dev server
cd react-todo
npm install
npm run dev

Vite prints a local URL (usually http://localhost:5173). Open it and you'll see the starter page โ€” editing a file updates the browser instantly, no manual refresh. Now clear out the demo so you start from a clean slate:

// src/App.jsx โ€” replace everything with this stub for now
import './App.css';

function App() {
  return (
    <div className="app">
      <h1>โœ… My To-Dos</h1>
    </div>
  );
}

export default App;

Leave src/main.jsx exactly as Vite generated it โ€” it does the one job of mounting <App /> into the page's #root div, wrapped in <StrictMode>.

๐Ÿ“– What is <StrictMode> doing?

In development, React's StrictMode deliberately runs your components (and some effects) twice to surface accidental side effects. It does nothing in production. If an effect fires twice while developing, that's Strict Mode doing its job โ€” write effects so a double-run is harmless and you'll never be bitten.

Stage 2 โ€” State Shape & App Shell

Before writing features, decide what a to-do is. Getting the state shape right up front makes every later stage fall into place. Each to-do is a plain object with a stable, unique id, its text, and a completed boolean:

// The shape of one to-do
{
  id: "lqk3f9a7",     // stable + unique โ€” used as the React key
  text: "Buy milk",
  completed: false
}

The App component owns two pieces of state โ€” the array of todos and the current filter โ€” and defines every operation that changes them. Here's the shell we'll fill in stage by stage:

// src/App.jsx
import { useState } from 'react';
import TodoForm from './components/TodoForm';
import TodoFilter from './components/TodoFilter';
import TodoList from './components/TodoList';
import './App.css';

function App() {
  // The single source of truth for the whole app.
  const [todos, setTodos] = useState([]);
  const [filter, setFilter] = useState('all'); // 'all' | 'active' | 'completed'

  // CRUD handlers go here (Stages 3โ€“5)...

  return (
    <div className="app">
      <h1>โœ… My To-Dos</h1>
      <TodoForm />
      <TodoFilter />
      <TodoList />
    </div>
  );
}

export default App;

โš ๏ธ Never use Date.now() alone as an id in a fast loop

A common shortcut is id: Date.now(). It's fine for slow, human-paced adds, but two items created in the same millisecond would collide. The modern, bulletproof choice is the built-in crypto.randomUUID(), which every current browser supports. We'll use it in the next stage so your keys are always unique.

Stage 3 โ€” Add & Render (Create + Read)

Now the first real feature: add a to-do and see it appear โ€” the "C" and "R" of CRUD. It introduces the two patterns you'll reuse everywhere: a controlled input and an immutable state update.

The add handler in App

Adding never mutates the existing array. Instead we build a brand-new array with the spread operator and hand it to setTodos. React sees a new array reference and re-renders. Mutating in place (e.g. todos.push(...)) would leave React unaware anything changed.

// Inside App() โ€” Create
const addTodo = (text) => {
  const newTodo = {
    id: crypto.randomUUID(),   // stable, unique id
    text: text.trim(),
    completed: false,
  };
  // Functional update: build a NEW array from the previous state.
  setTodos((prev) => [...prev, newTodo]);
};

๐Ÿ’ก Why setTodos(prev => ...) instead of setTodos([...todos, newTodo])?

Passing a function gives you the latest state as prev, even if several updates are batched together. It's the safe habit for any update that derives from the current value. Both forms work here, but the functional form never goes stale โ€” build the habit now.

TodoForm โ€” a controlled input

A controlled component ties the input's value to state and updates that state on every keystroke via onChange. React is the single source of truth for what's in the box โ€” which is exactly what lets you clear it after submit with one line.

// src/components/TodoForm.jsx
import { useState } from 'react';

function TodoForm({ onAdd }) {
  const [text, setText] = useState('');

  const handleSubmit = (e) => {
    e.preventDefault();          // stop the page from reloading
    if (!text.trim()) return;    // ignore empty / whitespace-only submits
    onAdd(text);                 // hand the value up to App
    setText('');                 // clear the controlled input
  };

  return (
    <form className="todo-form" onSubmit={handleSubmit}>
      <label htmlFor="new-todo" className="visually-hidden">New to-do</label>
      <input
        id="new-todo"
        type="text"
        value={text}                              // controlled by state
        onChange={(e) => setText(e.target.value)}  // state follows the input
        placeholder="What needs to be done?"
        autoComplete="off"
      />
      <button type="submit">Add</button>
    </form>
  );
}

export default TodoForm;

TodoList and TodoItem โ€” Read

The list maps each to-do to a TodoItem, passing a stable key. React uses the key to track which item is which between renders โ€” so when you later reorder, edit, or delete, it updates the right row instead of scrambling the DOM. Use the id, never the array index.

// src/components/TodoList.jsx
import TodoItem from './TodoItem';

function TodoList({ todos, onToggle, onEdit, onDelete }) {
  if (todos.length === 0) {
    return <p className="empty-state">Nothing here yet โ€” add your first to-do above. ๐ŸŽ‰</p>;
  }

  return (
    <ul className="todo-list">
      {todos.map((todo) => (
        <TodoItem
          key={todo.id}          {/* stable key โ€” NOT the array index */}
          todo={todo}
          onToggle={onToggle}
          onEdit={onEdit}
          onDelete={onDelete}
        />
      ))}
    </ul>
  );
}

export default TodoList;

For now, TodoItem can be a simple read-only row that renders <span className="todo-text">{todo.text}</span> inside an <li>; we'll add the checkbox, edit, and delete controls in the next two stages. Finally, wire them into App's return โ€” <TodoForm onAdd={addTodo} /> and <TodoList todos={todos} /> โ€” so the form can add and the list can render.

โš ๏ธ Why the index is a dangerous key

Using key={index} looks harmless until you delete or reorder. Because indexes shift, React can associate the wrong component with the wrong data โ€” a half-typed edit box can jump to another row, or a checkbox can appear to toggle the wrong item. A key must be stable and tied to the data, which is exactly why we generated an id.

Stage 4 โ€” Toggle & Delete

Two more operations, both one-liners once you think immutably. Toggle maps over the array and returns a new object for the matching item (spreading the old one and flipping completed), leaving every other item untouched. Delete is just filter.

// Inside App() โ€” Update (toggle) and Delete
const toggleTodo = (id) => {
  setTodos((prev) =>
    prev.map((todo) =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    )
  );
};

const deleteTodo = (id) => {
  setTodos((prev) => prev.filter((todo) => todo.id !== id));
};

โœ… The immutable-update reflex

Every state change in this app is one of three moves: add with [...prev, item], change one with prev.map(...) returning a fresh object, or remove with prev.filter(...). None of them touch the original array. Internalize these three and you can update any React state correctly.

Pass the two new handlers down from App to TodoList (add onToggle={toggleTodo} and onDelete={deleteTodo} alongside todos), and give TodoItem a checkbox and a delete button:

// src/components/TodoItem.jsx โ€” now with toggle + delete
function TodoItem({ todo, onToggle, onDelete }) {
  return (
    <li className={`todo-item ${todo.completed ? 'completed' : ''}`}>
      <input
        type="checkbox"
        checked={todo.completed}
        onChange={() => onToggle(todo.id)}
        aria-label={`Mark "${todo.text}" complete`}
      />
      <span className="todo-text">{todo.text}</span>
      <button className="delete-btn" onClick={() => onDelete(todo.id)}>
        Delete
      </button>
    </li>
  );
}

export default TodoItem;

A completed item gets the completed class; a line of CSS (Stage 7's stylesheet) strikes it through. Notice the checkbox is controlled too โ€” its checked comes from state, and onChange reports up. React never lets the DOM and your data drift apart.

Stage 5 โ€” Edit In Place (Update)

Editing is the richest interaction, and it shows off a key idea: local UI state belongs in the component that owns the UI. Whether a row is currently being edited, and the draft text while you type, are concerns of that one row โ€” not the whole app. So TodoItem keeps its own isEditing and draft state, and only calls up to App when you save.

First, the update handler in App โ€” same map pattern as toggle, but changing text:

// Inside App() โ€” Update (edit text)
const editTodo = (id, newText) => {
  setTodos((prev) =>
    prev.map((todo) =>
      todo.id === id ? { ...todo, text: newText.trim() } : todo
    )
  );
};

Now the full TodoItem. When editing, it swaps the text span for a controlled input; Enter or the Save button commits, Escape cancels. This is the complete, final version of the component:

// src/components/TodoItem.jsx โ€” final version
import { useState } from 'react';

function TodoItem({ todo, onToggle, onEdit, onDelete }) {
  const [isEditing, setIsEditing] = useState(false);
  const [draft, setDraft] = useState(todo.text);

  const save = () => {
    const trimmed = draft.trim();
    if (trimmed && trimmed !== todo.text) {
      onEdit(todo.id, trimmed);     // only call up if something actually changed
    } else {
      setDraft(todo.text);          // nothing valid to save โ€” reset the draft
    }
    setIsEditing(false);
  };

  const cancel = () => {
    setDraft(todo.text);            // discard edits
    setIsEditing(false);
  };

  const handleKeyDown = (e) => {
    if (e.key === 'Enter') save();
    if (e.key === 'Escape') cancel();
  };

  return (
    <li className={`todo-item ${todo.completed ? 'completed' : ''}`}>
      <input
        type="checkbox"
        checked={todo.completed}
        onChange={() => onToggle(todo.id)}
        aria-label={`Mark "${todo.text}" complete`}
      />

      {isEditing ? (
        <input
          type="text"
          className="edit-input"
          value={draft}
          onChange={(e) => setDraft(e.target.value)}
          onKeyDown={handleKeyDown}
          autoFocus
        />
      ) : (
        <span className="todo-text" onDoubleClick={() => setIsEditing(true)}>
          {todo.text}
        </span>
      )}

      <div className="todo-actions">
        {isEditing ? (
          <button className="save-btn" onClick={save}>Save</button>
        ) : (
          <button className="edit-btn" onClick={() => setIsEditing(true)}>Edit</button>
        )}
        <button className="delete-btn" onClick={() => onDelete(todo.id)}>Delete</button>
      </div>
    </li>
  );
}

export default TodoItem;

๐Ÿ’ก Local state vs. lifted state โ€” how to decide

Ask: "does anyone else need this value?" The list of todos does โ€” TodoList, TodoFilter, and persistence all read it, so it's lifted to App. Whether one row is mid-edit does not โ€” only that row cares โ€” so it stays local in TodoItem. Keeping local things local keeps App small and re-renders cheap.

Wire the new onEdit={editTodo} prop through App โ†’ TodoList โ†’ TodoItem alongside the toggle and delete props. (You'll see all four passed together in Stage 6's final wiring.)

Stage 6 โ€” Filter

The filter is a great lesson in derived state: you don't store a second list of "visible todos." You store the single todos array plus a small filter string, and compute the visible list on each render. One source of truth, no risk of the two lists disagreeing.

// Inside App() โ€” derive the visible list from state (don't store it)
const visibleTodos = todos.filter((todo) => {
  if (filter === 'active')    return !todo.completed;
  if (filter === 'completed') return todo.completed;
  return true; // 'all'
});

TodoFilter is three buttons that report the chosen filter up and highlight the active one:

// src/components/TodoFilter.jsx
const FILTERS = ['all', 'active', 'completed'];

function TodoFilter({ current, onChange }) {
  return (
    <div className="todo-filter" role="group" aria-label="Filter to-dos">
      {FILTERS.map((f) => (
        <button
          key={f}
          className={f === current ? 'active' : ''}
          aria-pressed={f === current}
          onClick={() => onChange(f)}
        >
          {f[0].toUpperCase() + f.slice(1)}
        </button>
      ))}
    </div>
  );
}

export default TodoFilter;

Then render the filter and feed TodoList the derived list instead of the raw one:

// Inside App()'s return
<TodoFilter current={filter} onChange={setFilter} />
<TodoList
  todos={visibleTodos}         {/* the derived list, not `todos` */}
  onToggle={toggleTodo}
  onEdit={editTodo}
  onDelete={deleteTodo}
/>

๐Ÿ“– Data flow for one filter click

Click "Active" โ†’ TodoFilter calls onChange('active') (which is setFilter) โ†’ App re-renders โ†’ visibleTodos recomputes โ†’ TodoList receives the shorter list. You never touched the DOM; you changed one string and the UI followed.

Stage 7 โ€” Persist with localStorage

Right now a refresh wipes everything. The fix is two small additions to the App shell you started in Stage 2 โ€” no new components, just two hooks working together:

// src/App.jsx โ€” the two persistence pieces (add to your existing App)
import { useState, useEffect } from 'react';

// 1. Lazy initializer: a function passed to useState runs ONCE, on the
//    first render, seeding state from whatever was saved.
const [todos, setTodos] = useState(() => {
  try {
    const saved = localStorage.getItem('todos');
    return saved ? JSON.parse(saved) : [];
  } catch {
    return []; // corrupted / blocked storage โ€” start empty rather than crash
  }
});

// 2. Effect: write back to localStorage whenever the list changes.
useEffect(() => {
  localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);   // dependency array: run only when `todos` changes

That's the whole feature. Combined with the CRUD handlers from Stages 3โ€“5 and the derived visibleTodos from Stage 6, your App is complete: add a few items, refresh the page, and they're all still there.

โš ๏ธ Wrap JSON.parse in a try/catch

Reading from localStorage can fail โ€” the value could be corrupted, or storage could be blocked in private-browsing modes. An unguarded JSON.parse would throw during the very first render and crash the whole app. The try/catch in the lazy initializer degrades gracefully to an empty list instead.

A minimal App.css

Styling isn't the point here, but a little polish helps you see completed items strike through and the active filter highlight. Drop this in src/App.css:

.app { max-width: 520px; margin: 2rem auto; padding: 0 1rem; font-family: system-ui, sans-serif; }
.todo-form { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
.todo-form input { flex: 1; padding: 0.6rem; }
.todo-list { list-style: none; padding: 0; }
.todo-item { display: flex; align-items: center; gap: 0.6rem; padding: 0.6rem; border-bottom: 1px solid #ddd; }
.todo-item.completed .todo-text { text-decoration: line-through; opacity: 0.6; }
.todo-text { flex: 1; }
.todo-filter button.active { font-weight: bold; text-decoration: underline; }
.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }

Stretch Goals

Finished the required build with time to spare? Level it up โ€” pick whichever excites you; none are needed to pass the rubric.

  • ๐Ÿงน Clear completed โ€” one button that filters out every completed item
  • ๐Ÿ”ข Live counts โ€” show active / completed / total (a taste of the remaining line above)
  • ๐Ÿช Extract a custom hook โ€” move the todos state + persistence into useTodos() so App gets even thinner
  • ๐Ÿ” Search box โ€” a controlled input that further narrows the visible list
  • โ†•๏ธ Reorder โ€” "move up / move down" buttons that swap array positions immutably
  • ๐ŸŽจ Dark mode โ€” a theme toggle whose choice also persists to localStorage

Custom-hook starter

The whole todos-plus-persistence block extracts cleanly into a reusable hook. This is the exact refactor real React apps make as they grow โ€” and a perfect warm-up for next week:

// src/hooks/useTodos.js
import { useState, useEffect } from 'react';

export function useTodos() {
  const [todos, setTodos] = useState(() => {
    try { return JSON.parse(localStorage.getItem('todos')) ?? []; }
    catch { return []; }
  });

  useEffect(() => {
    localStorage.setItem('todos', JSON.stringify(todos));
  }, [todos]);

  // ...the same addTodo / toggleTodo / editTodo / deleteTodo you already wrote...

  return { todos, addTodo, toggleTodo, editTodo, deleteTodo };
}

Then App becomes almost pure layout: const { todos, addTodo, ... } = useTodos(); and you're done. The hook is nothing but the state and functions you already have โ€” extracting logic into a hook is renaming, not rewriting.

Self-Check Rubric

Before you call this done, grade yourself against the rubric. Aim to answer "yes" to everything in the first two columns โ€” the stretch column is bonus.

AreaMeets expectations (required)Exceeds (stretch)
Create & Read A controlled form adds a to-do; the list renders it; empty submits are ignored Live counts of active/completed/total items
Update Checkbox toggles complete; edit-in-place changes the text and saves Enter saves, Escape cancels, double-click to edit
Delete & Filter Delete removes an item; All / Active / Completed filter works Clear-completed button; a search box
State discipline All updates immutable (map/filter/spread); no direct mutation Todos state + persistence extracted into a custom hook
Keys & components Function components + hooks only; a stable id key (not the index) Local UI state kept in TodoItem, not lifted needlessly
Persistence & quality localStorage survives refresh; no console errors or key warnings Deployed live; graceful handling of corrupted storage

๐Ÿงช Final testing checklist

  • โ˜ Typing a task and pressing Add renders it immediately; the input clears
  • โ˜ Submitting an empty or whitespace-only field does nothing
  • โ˜ The checkbox strikes an item through and un-strikes it
  • โ˜ Editing an item saves the new text; Escape discards the change
  • โ˜ Delete removes the correct item โ€” even after reordering the list
  • โ˜ All / Active / Completed each show the right subset and highlight correctly
  • โ˜ Refreshing the page keeps every to-do (localStorage works)
  • โ˜ No red errors and no "unique key" warnings in the console

Summary

๐ŸŽ‰ What You Built

  • A full-CRUD React to-do app โ€” add, list, toggle, edit-in-place, delete โ€” scaffolded with Vite
  • A single source of truth in App, with data flowing down via props and events flowing up via callbacks
  • Immutable state updates using map, filter, and the spread operator โ€” never mutating in place
  • Controlled inputs for both adding and editing, and stable keys tied to each item's id
  • Derived filtering (compute, don't store) and localStorage persistence via useEffect

This project is proof that Week 4 stuck. You took components, props, state, events, and effects and combined them into an app people would actually use. Every pattern here โ€” lift state up, update immutably, render lists with keys, control your inputs โ€” is the everyday grammar of React. You'll write these same moves in every component from now on.

๐Ÿ“š Additional Resources

๐Ÿš€ What's Next?

You just felt the pain point that the next chapter solves. Passing onToggle, onEdit, and onDelete down through TodoList to TodoItem is "prop drilling" โ€” harmless here, but tedious once components nest deeper. Week 5 opens with the useContext hook, which lets a deeply nested component read shared state directly, without threading props through every layer in between.

๐ŸŽ‰ You finished Week 4!

You shipped a real React app with full CRUD and persistence. Deploy it, share the URL, and add it to your portfolio โ€” you're thinking in components now.