Project: To-Do App
Time to put the pieces together. In this project you will build a to-do list app for Priya, who wants to track household tasks and errands. It is a small app, but it uses almost everything from the course so far: components and props, lists and keys, a reducer for the main state, a React 19 form action, derived data for filters, refs for focusing an input, and an effect that saves the list in the browser.
You will build it in seven steps. Each step adds one idea, and the complete app is at the end, ready to run in the Try-it editor. If you work in a Vite project instead, put each component in its own file as you learned in Components.
Step 1: plan the components and the state
Before writing code, sketch the screen and split it into components. Draw boxes around each part that has its own job:
TodoApp: the whole app. It owns the state.AddTodoForm: the input and "Add" button.TodoItem: one task with a checkbox, its text, and "Edit" and "Delete" buttons.FilterBar: buttons to show all, active or done tasks.
Next, decide the minimal state. Ask of each piece of data: does it change over time, and can it be calculated from something else?
| Data | State? | Why |
|---|---|---|
| The list of tasks | Yes, in TodoApp | Changes when the user adds, edits or deletes |
| The chosen filter | Yes, in TodoApp | Changes when a filter button is clicked |
| The visible tasks | No | Calculated from tasks and filter |
| "N tasks left" | No | Calculated from tasks |
| Whether one item is being edited | Yes, inside TodoItem | Only that item cares |
Each task is an object { id, text, done }. The id is a stable key, created once with crypto.randomUUID(), which every modern browser provides.
Step 2: build a static version
Build the UI with fixed data first, with no state at all. This lets you get the markup and props right before thinking about behaviour.
import { createRoot } from "react-dom/client";
const sample = [
{ id: "t1", text: "Pay electricity bill", done: false },
{ id: "t2", text: "Buy notebooks for Aarav", done: true },
];
function TodoItem({ todo }) {
return (
<li>
<input type="checkbox" checked={todo.done} readOnly />{" "}
<span style={{ textDecoration: todo.done ? "line-through" : "none" }}>{todo.text}</span>{" "}
<button>Edit</button> <button>Delete</button>
</li>
);
}
function TodoApp() {
return (
<main>
<h2>Priya's to-do list</h2>
<form>
<input name="text" placeholder="What needs doing?" /> <button>Add</button>
</form>
<ul style={{ listStyle: "none", padding: 0 }}>
{sample.map((t) => <TodoItem key={t.id} todo={t} />)}
</ul>
<p>1 task left</p>
</main>
);
}
createRoot(document.getElementById("root")).render(<TodoApp />);The checkbox has readOnly for now, because a checked value without an onChange handler would trigger a React warning. Nothing works yet, and that is fine.
Step 3: hold the tasks in a reducer
Tasks can be added, toggled, edited, deleted and cleared. That is five different updates to the same array, so a reducer keeps all of them in one place, as you learned in useReducer:
function todosReducer(todos, action) {
switch (action.type) {
case "added":
return [...todos, { id: crypto.randomUUID(), text: action.text, done: false }];
case "toggled":
return todos.map((t) => (t.id === action.id ? { ...t, done: !t.done } : t));
case "edited":
return todos.map((t) => (t.id === action.id ? { ...t, text: action.text } : t));
case "deleted":
return todos.filter((t) => t.id !== action.id);
case "clearedDone":
return todos.filter((t) => !t.done);
default:
throw new Error(`Unknown action: ${action.type}`);
}
}Every case returns a new array, and changed tasks are new objects created with spread, so nothing is mutated. Is crypto.randomUUID() inside a reducer impure? Strictly, yes: it returns a different value each call. It works here because React runs the reducer once per dispatched event in production, but if you want a perfectly pure reducer (Strict Mode may call reducers twice in development), create the id in the event handler and pass it in the action.
In TodoApp, replace the sample array with const [todos, dispatch] = useReducer(todosReducer, initialTodos);. Child components never receive dispatch directly; they get small callback props such as onToggle, so they stay reusable and easy to test.
Step 4: add tasks with a form action
The add form only needs the text when it is submitted, so an uncontrolled input with a React 19 form action is the simplest choice. React passes the FormData, and after the action runs, it clears the input automatically:
function AddTodoForm({ onAdd }) {
function addAction(formData) {
const text = String(formData.get("text")).trim();
if (text) onAdd(text);
}
return (
<form action={addAction}>
<input name="text" placeholder="What needs doing?" aria-label="New task" />
<button>Add</button>
</form>
);
}
// In TodoApp:
<AddTodoForm onAdd={(text) => dispatch({ type: "added", text })} />trim() ignores tasks that are only spaces. Pressing Enter in the input submits the form too, without any extra code. See Form Actions & useActionState for async actions and pending states.
Step 5: filters as derived data
The filter is a single string in state: "all", "active" or "done". The visible list is not stored; it is calculated on every render. A lookup object maps each filter name to a test function:
const FILTERS = { all: () => true, active: (t) => !t.done, done: (t) => t.done };
const [filter, setFilter] = useState("all");
const visible = todos.filter(FILTERS[filter]);
const left = todos.filter((t) => !t.done).length;FilterBar renders one button per key of FILTERS and marks the current one with aria-pressed, which tells screen readers which filter is active. Because visible and left are derived, they can never disagree with the real list.
Step 6: inline editing with a ref
Editing is local to one item, so TodoItem gets its own state: editing (a boolean) and draft (the text being typed). When editing starts, the input should be focused with its text selected. Focusing is a side effect on a DOM node, which is a job for a ref plus an effect:
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(todo.text);
const inputRef = useRef(null);
useEffect(() => {
if (editing) inputRef.current.select(); // focuses and selects the text
}, [editing]);
function save() {
const text = draft.trim();
if (text) onEdit(todo.id, text);
else setDraft(todo.text); // empty text: undo the change
setEditing(false);
}The edit input saves on Enter or when it loses focus (onBlur), and Escape cancels. Each TodoItem has its own editing state, and because items use stable id keys, that state stays attached to the right task even when the list changes. See Refs & the DOM for more about refs.
Step 7: save tasks in localStorage
Finally, tasks should survive a page refresh. Two small pieces do this:
function loadTodos() {
try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY));
if (Array.isArray(saved)) return saved;
} catch {
// no storage or invalid data: start with the sample list
}
return [{ id: "t1", text: "Pay electricity bill", done: false }];
}
const [todos, dispatch] = useReducer(todosReducer, undefined, loadTodos);
useEffect(() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
} catch {
// storage unavailable: the app still works for this visit
}
}, [todos]);The third argument of useReducer is an init function, called once to build the starting state, so the saved data is read only on the first render. The effect writes the list every time todos changes. Both use try...catch, because storage can be blocked (for example in some private browsing modes) or contain invalid data, and the app should still work.
The complete app
Here is everything together. Add a few tasks, tick some off, double-click a task (or press "Edit") to change it, switch filters, then run the example again: your list is still there.
import { useReducer, useEffect, useState, useRef } from "react";
import { createRoot } from "react-dom/client";
const STORAGE_KEY = "todo-app-v1";
function todosReducer(todos, action) {
switch (action.type) {
case "added":
return [...todos, { id: crypto.randomUUID(), text: action.text, done: false }];
case "toggled":
return todos.map((t) => (t.id === action.id ? { ...t, done: !t.done } : t));
case "edited":
return todos.map((t) => (t.id === action.id ? { ...t, text: action.text } : t));
case "deleted":
return todos.filter((t) => t.id !== action.id);
case "clearedDone":
return todos.filter((t) => !t.done);
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
function loadTodos() {
try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY));
if (Array.isArray(saved)) return saved;
} catch {
// no storage or invalid data: start with the sample list
}
return [
{ id: "t1", text: "Pay electricity bill", done: false },
{ id: "t2", text: "Buy notebooks for Aarav", done: true },
];
}
function AddTodoForm({ onAdd }) {
function addAction(formData) {
const text = String(formData.get("text")).trim();
if (text) onAdd(text);
}
return (
<form action={addAction} style={{ display: "flex", gap: 6 }}>
<input name="text" placeholder="What needs doing?" aria-label="New task" style={{ flex: 1 }} />
<button>Add</button>
</form>
);
}
function TodoItem({ todo, onToggle, onEdit, onDelete }) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(todo.text);
const inputRef = useRef(null);
useEffect(() => {
if (editing) inputRef.current.select();
}, [editing]);
function save() {
const text = draft.trim();
if (text) onEdit(todo.id, text);
else setDraft(todo.text);
setEditing(false);
}
return (
<li style={{ display: "flex", gap: 6, alignItems: "center", padding: "4px 0" }}>
<input type="checkbox" checked={todo.done} onChange={() => onToggle(todo.id)} aria-label={`Done: ${todo.text}`} />
{editing ? (
<input
ref={inputRef}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={save}
onKeyDown={(e) => {
if (e.key === "Enter") save();
if (e.key === "Escape") {
setDraft(todo.text);
setEditing(false);
}
}}
aria-label="Edit task"
/>
) : (
<span onDoubleClick={() => setEditing(true)} style={{ flex: 1, textDecoration: todo.done ? "line-through" : "none" }}>
{todo.text}
</span>
)}
<button onClick={() => setEditing(true)}>Edit</button>
<button onClick={() => onDelete(todo.id)} aria-label={`Delete ${todo.text}`}>Delete</button>
</li>
);
}
const FILTERS = { all: () => true, active: (t) => !t.done, done: (t) => t.done };
function FilterBar({ filter, onChange }) {
return (
<div style={{ display: "flex", gap: 6 }}>
{Object.keys(FILTERS).map((name) => (
<button key={name} onClick={() => onChange(name)} aria-pressed={filter === name} style={{ fontWeight: filter === name ? 700 : 400 }}>
{name}
</button>
))}
</div>
);
}
function TodoApp() {
const [todos, dispatch] = useReducer(todosReducer, undefined, loadTodos);
const [filter, setFilter] = useState("all");
useEffect(() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
} catch {
// storage unavailable: the app still works for this visit
}
}, [todos]);
const visible = todos.filter(FILTERS[filter]);
const left = todos.filter((t) => !t.done).length;
return (
<main style={{ maxWidth: 420, fontFamily: "system-ui" }}>
<h2>Priya's to-do list</h2>
<AddTodoForm onAdd={(text) => dispatch({ type: "added", text })} />
<ul style={{ listStyle: "none", padding: 0 }}>
{visible.map((t) => (
<TodoItem
key={t.id}
todo={t}
onToggle={(id) => dispatch({ type: "toggled", id })}
onEdit={(id, text) => dispatch({ type: "edited", id, text })}
onDelete={(id) => dispatch({ type: "deleted", id })}
/>
))}
</ul>
{visible.length === 0 && <p>Nothing here.</p>}
<p>{left} {left === 1 ? "task" : "tasks"} left</p>
<FilterBar filter={filter} onChange={setFilter} />
<p>
<button onClick={() => dispatch({ type: "clearedDone" })} disabled={left === todos.length}>
Clear completed
</button>
</p>
</main>
);
}
createRoot(document.getElementById("root")).render(<TodoApp />);Take a moment to trace one interaction. Clicking a checkbox calls onToggle(id), which dispatches { type: "toggled", id }. The reducer returns a new array, React re-renders TodoApp, visible and left are recalculated, the changed TodoItem re-renders with a line through its text, and the effect saves the new list. Every step in that chain is something you learned in an earlier lesson.
Ideas to extend the app
- Add a due date to each task with an
<input type="date">and sort tasks by date withtoSorted(). - Move
todosanddispatchinto context so a header component can show the count without props. - Add "move up" and "move down" buttons that reorder tasks with
toSpliced(). - Write Vitest tests for
todosReducerand a Testing Library test for adding a task, as in Testing with Vitest. - Convert the project to TypeScript with a
Todotype and a discriminated union for actions.
Key points
- Plan first: split the UI into components, then find the minimal state and where it lives.
- Build a static version with props before adding state and behaviour.
- A reducer keeps all task updates in one pure, testable function.
- A React 19 form action reads the input with
FormDataand resets the form automatically. - Filters and counts are derived during render, never stored.
- Item-level state, a ref and an effect handle inline editing; an init function and an effect handle
localStorage.
Exercise
Extend the complete app with a "priority" for each task: a <select> in the add form with "low", "normal" and "high" (default "normal"). Store it on each task, show high-priority tasks in bold with a small "(high)" label, and add a fourth filter button "high" that shows only high-priority tasks that are not done.
Show solution
// 1. In AddTodoForm: read the priority and pass it on
function addAction(formData) {
const text = String(formData.get("text")).trim();
const priority = String(formData.get("priority"));
if (text) onAdd(text, priority);
}
// ...inside the form:
<select name="priority" defaultValue="normal" aria-label="Priority">
<option value="low">low</option>
<option value="normal">normal</option>
<option value="high">high</option>
</select>
// 2. In the reducer: store it
case "added":
return [...todos, { id: crypto.randomUUID(), text: action.text, priority: action.priority, done: false }];
// 3. In TodoApp: dispatch it and add the filter
<AddTodoForm onAdd={(text, priority) => dispatch({ type: "added", text, priority })} />
const FILTERS = {
all: () => true,
active: (t) => !t.done,
done: (t) => t.done,
high: (t) => t.priority === "high" && !t.done,
};
// 4. In TodoItem: highlight high-priority tasks
<span style={{ flex: 1, fontWeight: todo.priority === "high" ? 700 : 400, textDecoration: todo.done ? "line-through" : "none" }}>
{todo.text} {todo.priority === "high" && <small>(high)</small>}
</span>The priority travels from the form through the action into the reducer, and the new filter is just one more entry in the FILTERS object, so FilterBar shows the extra button automatically. Old saved tasks have no priority, which simply counts as "not high".
Quick Quiz
1. Why is the list of visible tasks not stored in state?
Derived data is computed during render, so it can never get out of sync.
2. Why does the app use useReducer for the tasks?
A reducer collects add, toggle, edit, delete and clear logic in one testable function.
3. What does the third argument in useReducer(todosReducer, undefined, loadTodos) do?
React calls the init function once, so saved data is read only at the start.
4. Why does TodoItem keep its own editing state instead of TodoApp?
State should live in the lowest component that needs it; editing concerns only one item.
Learn React with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
