โ 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
useStateand 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
localStoragewithuseEffectso 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.
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
useStatehook โ reading state and calling its setter to trigger a re-render - Events โ
onClick,onChange,onSubmit, and callinge.preventDefault() - Immutable array methods โ
map,filter, and the spread operator[...arr]from Week 2 - The
useEffecthook โ 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
localStorageso 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
remainingline above) - ๐ช Extract a custom hook โ move the todos state + persistence into
useTodos()soAppgets 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.
| Area | Meets 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
localStoragepersistence viauseEffect
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
- React โ Official interactive tutorial
- React โ Updating arrays in state (immutability)
- React โ
useStatereference - React โ
useEffectreference - Vite โ Getting Started
๐ 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.