React course menu
React Project: Build a To-Do App Step by Step
Build a complete React to-do app: planning components and state, adding tasks with a controlled form, toggling and deleting, editing tasks inline, filtering all/active/completed, counts and clear completed, and saving to localStorage with a custom hook.
On this page
This project combines the core of the course (components, props, state, events, forms, lists and a custom hook) into a complete to-do app. Each step adds one feature and runs live below its code.
Step 1: Plan#
Data: a list of tasks, each { id, text, done }, plus the current filter.
Components:
App owns all state
├── AddTaskForm controlled input; calls onAdd(text)
├── FilterBar All / Active / Completed buttons
├── TaskList
│ └── TaskItem checkbox, text (or edit box), delete button
└── Footer "3 tasks left", Clear completedThe tasks live in App, because the form, the list and the footer all need them (lifting state up).
Step 2: Show and add tasks#
import { useState } from "react";
function AddTaskForm({ onAdd }) {
const [text, setText] = useState("");
function handleSubmit(e) {
e.preventDefault();
const trimmed = text.trim();
if (!trimmed) return;
onAdd(trimmed);
setText("");
}
return (
<form onSubmit={handleSubmit}>
<input value={text} onChange={(e) => setText(e.target.value)} placeholder="What needs doing?" />
<button type="submit">Add</button>
</form>
);
}
export default function App() {
const [tasks, setTasks] = useState([
{ id: 1, text: "Buy vegetables", done: false },
{ id: 2, text: "Finish React lesson", done: true },
]);
function addTask(text) {
setTasks([...tasks, { id: Date.now(), text, done: false }]);
}
return (
<div>
<h3>My tasks</h3>
<AddTaskForm onAdd={addTask} />
<ul>
{tasks.map((t) => <li key={t.id}>{t.text}</li>)}
</ul>
</div>
);
}Date.now() gives a unique-enough id for a personal app; crypto.randomUUID() is a more robust choice when ids must never collide.
Step 3: Toggle and delete#
import { useState } from "react";
function TaskItem({ task, onToggle, onDelete }) {
return (
<li>
<label style={{ textDecoration: task.done ? "line-through" : "none", color: task.done ? "#888" : "inherit" }}>
<input type="checkbox" checked={task.done} onChange={() => onToggle(task.id)} /> {task.text}
</label>{" "}
<button onClick={() => onDelete(task.id)} aria-label={`Delete ${task.text}`}>✕</button>
</li>
);
}
export default function App() {
const [tasks, setTasks] = useState([
{ id: 1, text: "Buy vegetables", done: false },
{ id: 2, text: "Finish React lesson", done: true },
{ id: 3, text: "Call grandparents", done: false },
]);
const toggleTask = (id) => setTasks(tasks.map((t) => (t.id === id ? { ...t, done: !t.done } : t)));
const deleteTask = (id) => setTasks(tasks.filter((t) => t.id !== id));
return (
<ul>
{tasks.map((t) => (
<TaskItem key={t.id} task={t} onToggle={toggleTask} onDelete={deleteTask} />
))}
</ul>
);
}map with a spread copy updates one task; filter removes one. Neither changes the original array, as immutable updates require.
Step 4: The complete app#
The final version adds inline editing (double-click a task), filters, a count of remaining tasks, "Clear completed", and saving to localStorage through a custom hook. It moves all task updates into a reducer, since there are now many kinds of change.
.app { max-width: 380px; font-family: system-ui, sans-serif; }
.app form { display: flex; gap: 6px; }
.app form input { flex: 1; padding: 6px; }
.filters button { margin-right: 4px; }
.filters button.active { font-weight: bold; text-decoration: underline; }
.tasks { list-style: none; padding: 0; }
.tasks li { display: flex; align-items: center; gap: 6px; padding: 4px 0; border-bottom: 1px solid #eee; }
.tasks li span { flex: 1; cursor: text; }
.tasks li.done span { text-decoration: line-through; color: #888; }
.footer { display: flex; justify-content: space-between; color: #555; font-size: 14px; }import { useEffect, useReducer, useState } from "react";
function useLocalStorageReducer(key, reducer, initial) {
const [state, dispatch] = useReducer(reducer, initial, (fallback) => {
try {
const saved = localStorage.getItem(key);
return saved ? JSON.parse(saved) : fallback;
} catch {
return fallback;
}
});
useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(state));
} catch {
// storage unavailable: keep working in memory
}
}, [key, state]);
return [state, dispatch];
}
function tasksReducer(tasks, action) {
switch (action.type) {
case "added":
return [...tasks, { id: action.id, text: action.text, done: false }];
case "toggled":
return tasks.map((t) => (t.id === action.id ? { ...t, done: !t.done } : t));
case "edited":
return tasks.map((t) => (t.id === action.id ? { ...t, text: action.text } : t));
case "deleted":
return tasks.filter((t) => t.id !== action.id);
case "clearedCompleted":
return tasks.filter((t) => !t.done);
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
const FILTERS = {
All: () => true,
Active: (t) => !t.done,
Completed: (t) => t.done,
};
function TaskItem({ task, dispatch }) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(task.text);
function save() {
const text = draft.trim();
if (text) dispatch({ type: "edited", id: task.id, text });
else setDraft(task.text);
setEditing(false);
}
return (
<li className={task.done ? "done" : ""}>
<input type="checkbox" checked={task.done} onChange={() => dispatch({ type: "toggled", id: task.id })} />
{editing ? (
<input
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={save}
onKeyDown={(e) => {
if (e.key === "Enter") save();
if (e.key === "Escape") { setDraft(task.text); setEditing(false); }
}}
/>
) : (
<span onDoubleClick={() => setEditing(true)} title="Double-click to edit">{task.text}</span>
)}
<button onClick={() => dispatch({ type: "deleted", id: task.id })} aria-label={`Delete ${task.text}`}>✕</button>
</li>
);
}
export default function App() {
const [tasks, dispatch] = useLocalStorageReducer("todo-tasks", tasksReducer, [
{ id: 1, text: "Buy vegetables", done: false },
{ id: 2, text: "Finish React lesson", done: true },
{ id: 3, text: "Call grandparents", done: false },
]);
const [filter, setFilter] = useState("All");
const [text, setText] = useState("");
function handleSubmit(e) {
e.preventDefault();
if (!text.trim()) return;
dispatch({ type: "added", id: Date.now(), text: text.trim() });
setText("");
}
const visible = tasks.filter(FILTERS[filter]);
const left = tasks.filter((t) => !t.done).length;
return (
<div className="app">
<h3>My tasks</h3>
<form onSubmit={handleSubmit}>
<input value={text} onChange={(e) => setText(e.target.value)} placeholder="What needs doing?" />
<button type="submit">Add</button>
</form>
<p className="filters">
{Object.keys(FILTERS).map((name) => (
<button key={name} className={name === filter ? "active" : ""} onClick={() => setFilter(name)}>
{name}
</button>
))}
</p>
{visible.length === 0 ? (
<p>Nothing here.</p>
) : (
<ul className="tasks">
{visible.map((t) => <TaskItem key={t.id} task={t} dispatch={dispatch} />)}
</ul>
)}
<div className="footer">
<span>{left} {left === 1 ? "task" : "tasks"} left</span>
{tasks.some((t) => t.done) && (
<button onClick={() => dispatch({ type: "clearedCompleted" })}>Clear completed</button>
)}
</div>
</div>
);
}Try it: add a task, tick some off, switch filters, double-click a task to edit it (Enter saves, Escape cancels), and clear completed tasks.
Notes on the design:
- One reducer holds every way the task list can change, so the components only dispatch actions.
- Derived values (
visible,left) are calculated from state during rendering, never stored separately. - Editing state (
editing,draft) lives inTaskItem, because no other component needs it. useReducer's third argument is an initialiser function: it runs once to load saved tasks.- Persistence: in your own project, tasks survive a page reload. The preview frame on this site cannot use
localStorage(browsers block it in sandboxed frames), so the hook falls back to keeping tasks in memory there.
Ideas to extend it#
- Due dates and sorting by date.
- Drag-and-drop reordering.
- A "Mark all as done" checkbox.
- Moving it to TypeScript with a
Tasktype and a typedActionunion. - Syncing tasks with a back-end API (see Fetching Data).
Practice exercise
Add a "Mark all as done" button to the final app that marks every task done, and is hidden when all tasks are already done.
Show hint
Add a reducer case "allCompleted" that returns tasks.map((t) => ({ ...t, done: true })), and show the button only when left > 0.
Show solution
Add this case to tasksReducer:
case "allCompleted":
return tasks.map((t) => ({ ...t, done: true }));and this button in App, for example inside the footer:
{left > 0 && <button onClick={() => dispatch({ type: "allCompleted" })}>Mark all as done</button>}Next project: Product Catalogue with Cart.
Finished this lesson?