Building a To-Do App with React: State, Hooks, and CRUD
This tutorial walks you through building a complete To‑Do application in React, focusing on state management, React Hooks, and CRUD operations (Create, Read, Update, Delete). You’ll start from a clean project, build the UI, implement behavior with hooks, persist data to localStorage, and structure your code in a maintainable way.
You’ll end with an app that can:
- Create tasks
- List tasks
- Toggle completion
- Edit tasks
- Delete tasks
- Filter tasks (All / Active / Completed)
- Persist tasks across page refreshes
Table of Contents
- Prerequisites
- 1) Create a React project
- 2) Project structure
- 3) Understanding state and re-renders
- 4) Define the data model
- 5) Build the UI components (static first)
- 6) Implement CRUD with hooks
- 7) Derived state: filtering and counts
- 8) Persist to localStorage with useEffect
- 9) Common pitfalls (and how to avoid them)
- 10) Optional improvements
- Complete source code
Prerequisites
You should be comfortable with:
- JavaScript (ES6+):
const/let, arrow functions, array methods (map,filter) - Basic React concepts: components and props
Installed tools:
- Node.js 18+ recommended
- npm (comes with Node) or pnpm/yarn
Verify:
node -v
npm -v
1) Create a React project
You can use Vite for a fast dev environment.
npm create vite@latest react-todo -- --template react
cd react-todo
npm install
npm run dev
Vite will print a local dev URL (usually http://localhost:5173).
2) Project structure
We’ll use a simple structure:
react-todo/
src/
components/
TodoForm.jsx
TodoItem.jsx
TodoList.jsx
TodoFilters.jsx
App.jsx
main.jsx
index.css
Create the components folder and files:
mkdir -p src/components
touch src/components/TodoForm.jsx src/components/TodoItem.jsx src/components/TodoList.jsx src/components/TodoFilters.jsx
3) Understanding state and re-renders
React components render UI based on state and props.
- Props are inputs passed from parent to child.
- State is internal data that can change over time.
When state changes via a setter from useState, React schedules a re-render. A re-render means:
- The component function runs again.
- React compares the new output with the previous output (reconciliation).
- The DOM updates efficiently.
Why immutability matters
React state should be treated as immutable. Instead of changing an array/object in place, create a new one:
- Good:
setTodos(prev => prev.filter(...)) - Avoid:
todos.push(...)thensetTodos(todos)(mutates existing array)
Immutability helps React detect changes and prevents subtle bugs.
4) Define the data model
A todo item needs enough information to support CRUD:
{
id: "some-unique-id",
title: "Buy milk",
completed: false,
createdAt: 1700000000000
}
Notes:
id: should be stable and unique. We’ll usecrypto.randomUUID()(supported in modern browsers).title: the task text.completed: used for toggling and filtering.createdAt: useful for sorting or debugging; optional but helpful.
5) Build the UI components (static first)
A reliable approach is to build the UI structure first, then wire up behavior.
App layout
Replace src/App.jsx with:
import { useEffect, useMemo, useState } from "react";
import TodoForm from "./components/TodoForm";
import TodoList from "./components/TodoList";
import TodoFilters from "./components/TodoFilters";
import "./index.css";
const STORAGE_KEY = "todos:v1";
export default function App() {
const [todos, setTodos] = useState([]);
const [filter, setFilter] = useState("all"); // "all" | "active" | "completed"
return (
<div className="container">
<header className="header">
<h1>To-Do App</h1>
<p className="subtitle">React state, hooks, and CRUD in one small project.</p>
</header>
<main className="card">
<TodoForm onAdd={() => {}} />
<TodoFilters
filter={filter}
onChangeFilter={() => {}}
total={todos.length}
activeCount={0}
completedCount={0}
/>
<TodoList
todos={todos}
onToggle={() => {}}
onDelete={() => {}}
onEdit={() => {}}
/>
</main>
<footer className="footer">
<small>Data will be saved in your browser (localStorage).</small>
</footer>
</div>
);
}
At this stage, handlers are placeholders. The goal is to see the layout compile.
Add basic styling
Replace src/index.css with:
:root {
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
color: #111827;
background: #f3f4f6;
}
* { box-sizing: border-box; }
body {
margin: 0;
}
.container {
max-width: 760px;
margin: 40px auto;
padding: 0 16px;
}
.header {
margin-bottom: 16px;
}
.subtitle {
margin-top: 4px;
color: #4b5563;
}
.card {
background: white;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 16px;
box-shadow: 0 8px 24px rgba(0,0,0,0.06);
}
.footer {
margin-top: 16px;
color: #6b7280;
}
.row {
display: flex;
gap: 8px;
align-items: center;
}
input[type="text"] {
width: 100%;
padding: 10px 12px;
border: 1px solid #d1d5db;
border-radius: 10px;
font-size: 14px;
}
button {
padding: 10px 12px;
border: 1px solid #d1d5db;
border-radius: 10px;
background: #111827;
color: white;
cursor: pointer;
}
button.secondary {
background: white;
color: #111827;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.list {
margin-top: 12px;
padding: 0;
list-style: none;
}
.item {
display: flex;
gap: 10px;
align-items: center;
padding: 10px 8px;
border-bottom: 1px solid #f3f4f6;
}
.item:last-child {
border-bottom: none;
}
.title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.title.completed {
text-decoration: line-through;
color: #6b7280;
}
.muted {
color: #6b7280;
}
.pillbar {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 12px;
align-items: center;
justify-content: space-between;
}
.pills {
display: flex;
gap: 8px;
}
.pill {
padding: 6px 10px;
border-radius: 999px;
border: 1px solid #d1d5db;
background: white;
cursor: pointer;
}
.pill.active {
background: #111827;
color: white;
border-color: #111827;
}
TodoForm
Create src/components/TodoForm.jsx:
import { useState } from "react";
export default function TodoForm({ onAdd }) {
const [title, setTitle] = useState("");
function handleSubmit(e) {
e.preventDefault();
const trimmed = title.trim();
if (!trimmed) return;
onAdd(trimmed);
setTitle("");
}
return (
<form className="row" onSubmit={handleSubmit}>
<input
type="text"
value={title}
placeholder="Add a task (e.g., 'Read 10 pages')"
onChange={(e) => setTitle(e.target.value)}
aria-label="Todo title"
/>
<button type="submit" disabled={!title.trim()}>
Add
</button>
</form>
);
}
Key idea: controlled input
The input’s value is controlled by React state:
value={title}makes the input reflect state.onChangeupdates state.- This ensures the UI is always consistent with your data.
TodoList and TodoItem
Create src/components/TodoList.jsx:
import TodoItem from "./TodoItem";
export default function TodoList({ todos, onToggle, onDelete, onEdit }) {
if (todos.length === 0) {
return <p className="muted" style={{ marginTop: 12 }}>No tasks yet. Add one above.</p>;
}
return (
<ul className="list">
{todos.map((t) => (
<TodoItem
key={t.id}
todo={t}
onToggle={onToggle}
onDelete={onDelete}
onEdit={onEdit}
/>
))}
</ul>
);
}
Create src/components/TodoItem.jsx:
import { useState } from "react";
export default function TodoItem({ todo, onToggle, onDelete, onEdit }) {
const [isEditing, setIsEditing] = useState(false);
const [draft, setDraft] = useState(todo.title);
function startEdit() {
setDraft(todo.title);
setIsEditing(true);
}
function cancelEdit() {
setDraft(todo.title);
setIsEditing(false);
}
function saveEdit() {
const trimmed = draft.trim();
if (!trimmed) return;
onEdit(todo.id, trimmed);
setIsEditing(false);
}
return (
<li className="item">
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
aria-label={`Mark "${todo.title}" as completed`}
/>
{!isEditing ? (
<span className={`title ${todo.completed ? "completed" : ""}`}>
{todo.title}
</span>
) : (
<input
type="text"
value={draft}
onChange={(e) => setDraft(e.target.value)}
aria-label="Edit todo title"
/>
)}
{!isEditing ? (
<>
<button className="secondary" onClick={startEdit}>
Edit
</button>
<button className="secondary" onClick={() => onDelete(todo.id)}>
Delete
</button>
</>
) : (
<>
<button onClick={saveEdit} disabled={!draft.trim()}>
Save
</button>
<button className="secondary" onClick={cancelEdit}>
Cancel
</button>
</>
)}
</li>
);
}
Why local state inside TodoItem?
Editing is a temporary UI mode. The “draft” text is not committed until the user clicks Save. This is a great use case for component-local state:
- Global state (
todos) holds the source of truth. - Local state (
draft,isEditing) holds transient UI state.
Filters and counters
Create src/components/TodoFilters.jsx:
export default function TodoFilters({
filter,
onChangeFilter,
total,
activeCount,
completedCount,
}) {
return (
<div className="pillbar">
<div className="pills" role="tablist" aria-label="Todo filters">
<button
className={`pill ${filter === "all" ? "active" : ""}`}
onClick={() => onChangeFilter("all")}
type="button"
>
All ({total})
</button>
<button
className={`pill ${filter === "active" ? "active" : ""}`}
onClick={() => onChangeFilter("active")}
type="button"
>
Active ({activeCount})
</button>
<button
className={`pill ${filter === "completed" ? "active" : ""}`}
onClick={() => onChangeFilter("completed")}
type="button"
>
Completed ({completedCount})
</button>
</div>
<div className="muted">
Tip: Double-check tasks you’ve finished.
</div>
</div>
);
}
6) Implement CRUD with hooks
Now we connect the handlers in App.jsx and implement CRUD operations.
Create (add a todo)
In App.jsx, implement onAdd:
- Generate an
id - Create a new todo object
- Update the array immutably
Read (render todos)
“Read” is mostly just rendering state. But we’ll also compute a filtered view of todos.
Update (toggle and edit)
- Toggle: flip
completedfor a givenid - Edit: change
titlefor a givenid
Delete (remove a todo)
Remove a todo by filtering it out.
Replace src/App.jsx with the following complete version (we’ll add persistence in the next section, but this includes placeholders for it):
import { useEffect, useMemo, useState } from "react";
import TodoForm from "./components/TodoForm";
import TodoList from "./components/TodoList";
import TodoFilters from "./components/TodoFilters";
import "./index.css";
const STORAGE_KEY = "todos:v1";
function createTodo(title) {
return {
id: crypto.randomUUID(),
title,
completed: false,
createdAt: Date.now(),
};
}
export default function App() {
const [todos, setTodos] = useState([]);
const [filter, setFilter] = useState("all"); // "all" | "active" | "completed"
function handleAdd(title) {
const newTodo = createTodo(title);
setTodos((prev) => [newTodo, ...prev]);
}
function handleToggle(id) {
setTodos((prev) =>
prev.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t))
);
}
function handleDelete(id) {
setTodos((prev) => prev.filter((t) => t.id !== id));
}
function handleEdit(id, nextTitle) {
setTodos((prev) =>
prev.map((t) => (t.id === id ? { ...t, title: nextTitle } : t))
);
}
const activeCount = useMemo(
() => todos.filter((t) => !t.completed).length,
[todos]
);
const completedCount = useMemo(
() => todos.filter((t) => t.completed).length,
[todos]
);
const visibleTodos = useMemo(() => {
if (filter === "active") return todos.filter((t) => !t.completed);
if (filter === "completed") return todos.filter((t) => t.completed);
return todos;
}, [todos, filter]);
// Persistence will be added later; keep useEffect imports for now.
useEffect(() => {
// no-op for now
}, []);
return (
<div className="container">
<header className="header">
<h1>To-Do App</h1>
<p className="subtitle">React state, hooks, and CRUD in one small project.</p>
</header>
<main className="card">
<TodoForm onAdd={handleAdd} />
<TodoFilters
filter={filter}
onChangeFilter={setFilter}
total={todos.length}
activeCount={activeCount}
completedCount={completedCount}
/>
<TodoList
todos={visibleTodos}
onToggle={handleToggle}
onDelete={handleDelete}
onEdit={handleEdit}
/>
</main>
<footer className="footer">
<small>Data will be saved in your browser (localStorage).</small>
</footer>
</div>
);
}
What makes this “CRUD”?
- Create:
handleAddinserts a new todo. - Read:
visibleTodosis rendered byTodoList. - Update:
handleToggleandhandleEdit. - Delete:
handleDelete.
Even though this is a frontend-only app, the same conceptual operations exist in full-stack apps. The difference is: instead of calling an API, we update local state (and later persist to localStorage).
7) Derived state: filtering and counts
A common React question is: “Should I store counts and filtered arrays in state?”
Usually: no. If something can be derived from existing state, compute it during render (or memoize it if needed).
Why derived state is better
If you store activeCount in state, you must update it every time todos changes. That creates more places for bugs:
- Add todo → update count
- Toggle todo → update count
- Delete todo → update count
- Edit todo title → count unchanged (but you still must ensure you didn’t break it)
Instead, compute it from todos:
const activeCount = todos.filter(t => !t.completed).length;
Why useMemo?
useMemo caches a computed value until dependencies change. In this app, todos is small, so useMemo is optional. But it’s useful to learn:
- It documents that the value is derived.
- It avoids recomputing on unrelated renders.
Important: useMemo is not a guarantee of performance improvements; it’s a tool. Use it when computations are non-trivial or when referential equality matters.
8) Persist to localStorage with useEffect
Right now, refreshing the page will reset todos. Let’s save them in the browser.
We’ll implement two effects:
- Load todos once on mount.
- Save todos whenever they change.
Loading from localStorage
localStorage stores strings, so we use JSON.parse.
Add this helper in App.jsx (near the top):
function loadTodos() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed;
} catch {
return [];
}
}
Saving to localStorage
Use JSON.stringify and store after every change.
Now replace the no-op effect with two real effects. Update App.jsx like this:
import { useEffect, useMemo, useState } from "react";
import TodoForm from "./components/TodoForm";
import TodoList from "./components/TodoList";
import TodoFilters from "./components/TodoFilters";
import "./index.css";
const STORAGE_KEY = "todos:v1";
function createTodo(title) {
return {
id: crypto.randomUUID(),
title,
completed: false,
createdAt: Date.now(),
};
}
function loadTodos() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed;
} catch {
return [];
}
}
export default function App() {
const [todos, setTodos] = useState([]);
const [filter, setFilter] = useState("all");
// 1) Load once on mount
useEffect(() => {
setTodos(loadTodos());
}, []);
// 2) Save whenever todos change
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
}, [todos]);
function handleAdd(title) {
const newTodo = createTodo(title);
setTodos((prev) => [newTodo, ...prev]);
}
function handleToggle(id) {
setTodos((prev) =>
prev.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t))
);
}
function handleDelete(id) {
setTodos((prev) => prev.filter((t) => t.id !== id));
}
function handleEdit(id, nextTitle) {
setTodos((prev) =>
prev.map((t) => (t.id === id ? { ...t, title: nextTitle } : t))
);
}
const activeCount = useMemo(
() => todos.filter((t) => !t.completed).length,
[todos]
);
const completedCount = useMemo(
() => todos.filter((t) => t.completed).length,
[todos]
);
const visibleTodos = useMemo(() => {
if (filter === "active") return todos.filter((t) => !t.completed);
if (filter === "completed") return todos.filter((t) => t.completed);
return todos;
}, [todos, filter]);
return (
<div className="container">
<header className="header">
<h1>To-Do App</h1>
<p className="subtitle">React state, hooks, and CRUD in one small project.</p>
</header>
<main className="card">
<TodoForm onAdd={handleAdd} />
<TodoFilters
filter={filter}
onChangeFilter={setFilter}
total={todos.length}
activeCount={activeCount}
completedCount={completedCount}
/>
<TodoList
todos={visibleTodos}
onToggle={handleToggle}
onDelete={handleDelete}
onEdit={handleEdit}
/>
</main>
<footer className="footer">
<small>Saved locally in your browser (localStorage).</small>
</footer>
</div>
);
}
Why two effects?
- The “load” effect runs once because its dependency array is
[]. - The “save” effect runs whenever
todoschanges because its dependency array is[todos].
This separation keeps responsibilities clear and avoids tricky edge cases.
A subtle detail: initial state and saving
If you start with useState([]) and load later in an effect, the “save” effect will run after the first render too. In practice, React runs effects after paint, and the load effect will set todos, then the save effect will run with the updated value. This is usually fine.
If you want to avoid any chance of writing [] before loading, you can initialize state from loadTodos() directly:
const [todos, setTodos] = useState(() => loadTodos());
Then you can remove the “load once” effect entirely. That approach is often cleaner. However, it can be slightly less friendly in environments where localStorage is not available (SSR). Since this is a browser-only Vite app, it’s safe.
9) Common pitfalls (and how to avoid them)
1) Mutating state directly
Bad:
todos.push(newTodo);
setTodos(todos);
This mutates the existing array. React may not detect changes properly, and you can create bugs where UI doesn’t update as expected.
Good:
setTodos(prev => [newTodo, ...prev]);
2) Using array index as key
Bad:
{todos.map((t, i) => <TodoItem key={i} ... />)}
If you insert/remove items, indices shift, and React may reuse the wrong component instance. This is especially problematic with local state (like isEditing and draft).
Good:
{todos.map(t => <TodoItem key={t.id} ... />)}
3) Storing derived values in state
Avoid:
activeCountin statevisibleTodosin state
Instead, derive them from todos and filter.
4) Forgetting to trim input
Without trimming, users can add blank tasks that are just spaces. We used:
const trimmed = title.trim();
if (!trimmed) return;
5) Editing UX: keeping draft in sync
When you click “Edit”, you want the input to reflect the latest title. That’s why startEdit resets draft from todo.title.
10) Optional improvements
These are good next steps if you want to extend the app.
Add “Clear completed”
In App.jsx:
function handleClearCompleted() {
setTodos(prev => prev.filter(t => !t.completed));
}
Add a button in the UI (for example in the footer or near filters).
Add sorting
If you want newest first (we already insert at the top), or sort by completion:
const visibleTodos = useMemo(() => {
const filtered =
filter === "active" ? todos.filter(t => !t.completed)
: filter === "completed" ? todos.filter(t => t.completed)
: todos;
return [...filtered].sort((a, b) => b.createdAt - a.createdAt);
}, [todos, filter]);
Note the [...] copy before sorting—sort() mutates arrays.
Add keyboard support for editing
In TodoItem.jsx, save on Enter and cancel on Escape:
onKeyDown={(e) => {
if (e.key === "Enter") saveEdit();
if (e.key === "Escape") cancelEdit();
}}
Replace localStorage with a real API
CRUD becomes HTTP calls:
POST /todos(create)GET /todos(read)PATCH /todos/:id(update)DELETE /todos/:id(delete)
You’d still keep React state, but you’d synchronize it with server responses and handle loading/error states.
Complete source code
This section repeats the final versions for easy copy/paste.
src/App.jsx
import { useEffect, useMemo, useState } from "react";
import TodoForm from "./components/TodoForm";
import TodoList from "./components/TodoList";
import TodoFilters from "./components/TodoFilters";
import "./index.css";
const STORAGE_KEY = "todos:v1";
function createTodo(title) {
return {
id: crypto.randomUUID(),
title,
completed: false,
createdAt: Date.now(),
};
}
function loadTodos() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed;
} catch {
return [];
}
}
export default function App() {
const [todos, setTodos] = useState(() => loadTodos());
const [filter, setFilter] = useState("all");
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
}, [todos]);
function handleAdd(title) {
const newTodo = createTodo(title);
setTodos((prev) => [newTodo, ...prev]);
}
function handleToggle(id) {
setTodos((prev) =>
prev.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t))
);
}
function handleDelete(id) {
setTodos((prev) => prev.filter((t) => t.id !== id));
}
function handleEdit(id, nextTitle) {
setTodos((prev) =>
prev.map((t) => (t.id === id ? { ...t, title: nextTitle } : t))
);
}
const activeCount = useMemo(
() => todos.filter((t) => !t.completed).length,
[todos]
);
const completedCount = useMemo(
() => todos.filter((t) => t.completed).length,
[todos]
);
const visibleTodos = useMemo(() => {
if (filter === "active") return todos.filter((t) => !t.completed);
if (filter === "completed") return todos.filter((t) => t.completed);
return todos;
}, [todos, filter]);
return (
<div className="container">
<header className="header">
<h1>To-Do App</h1>
<p className="subtitle">React state, hooks, and CRUD in one small project.</p>
</header>
<main className="card">
<TodoForm onAdd={handleAdd} />
<TodoFilters
filter={filter}
onChangeFilter={setFilter}
total={todos.length}
activeCount={activeCount}
completedCount={completedCount}
/>
<TodoList
todos={visibleTodos}
onToggle={handleToggle}
onDelete={handleDelete}
onEdit={handleEdit}
/>
</main>
<footer className="footer">
<small>Saved locally in your browser (localStorage).</small>
</footer>
</div>
);
}
src/components/TodoForm.jsx
import { useState } from "react";
export default function TodoForm({ onAdd }) {
const [title, setTitle] = useState("");
function handleSubmit(e) {
e.preventDefault();
const trimmed = title.trim();
if (!trimmed) return;
onAdd(trimmed);
setTitle("");
}
return (
<form className="row" onSubmit={handleSubmit}>
<input
type="text"
value={title}
placeholder="Add a task (e.g., 'Read 10 pages')"
onChange={(e) => setTitle(e.target.value)}
aria-label="Todo title"
/>
<button type="submit" disabled={!title.trim()}>
Add
</button>
</form>
);
}
src/components/TodoList.jsx
import TodoItem from "./TodoItem";
export default function TodoList({ todos, onToggle, onDelete, onEdit }) {
if (todos.length === 0) {
return <p className="muted" style={{ marginTop: 12 }}>No tasks yet. Add one above.</p>;
}
return (
<ul className="list">
{todos.map((t) => (
<TodoItem
key={t.id}
todo={t}
onToggle={onToggle}
onDelete={onDelete}
onEdit={onEdit}
/>
))}
</ul>
);
}
src/components/TodoItem.jsx
import { useState } from "react";
export default function TodoItem({ todo, onToggle, onDelete, onEdit }) {
const [isEditing, setIsEditing] = useState(false);
const [draft, setDraft] = useState(todo.title);
function startEdit() {
setDraft(todo.title);
setIsEditing(true);
}
function cancelEdit() {
setDraft(todo.title);
setIsEditing(false);
}
function saveEdit() {
const trimmed = draft.trim();
if (!trimmed) return;
onEdit(todo.id, trimmed);
setIsEditing(false);
}
return (
<li className="item">
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
aria-label={`Mark "${todo.title}" as completed`}
/>
{!isEditing ? (
<span className={`title ${todo.completed ? "completed" : ""}`}>
{todo.title}
</span>
) : (
<input
type="text"
value={draft}
onChange={(e) => setDraft(e.target.value)}
aria-label="Edit todo title"
/>
)}
{!isEditing ? (
<>
<button className="secondary" onClick={startEdit} type="button">
Edit
</button>
<button
className="secondary"
onClick={() => onDelete(todo.id)}
type="button"
>
Delete
</button>
</>
) : (
<>
<button onClick={saveEdit} disabled={!draft.trim()} type="button">
Save
</button>
<button className="secondary" onClick={cancelEdit} type="button">
Cancel
</button>
</>
)}
</li>
);
}
src/components/TodoFilters.jsx
export default function TodoFilters({
filter,
onChangeFilter,
total,
activeCount,
completedCount,
}) {
return (
<div className="pillbar">
<div className="pills" role="tablist" aria-label="Todo filters">
<button
className={`pill ${filter === "all" ? "active" : ""}`}
onClick={() => onChangeFilter("all")}
type="button"
>
All ({total})
</button>
<button
className={`pill ${filter === "active" ? "active" : ""}`}
onClick={() => onChangeFilter("active")}
type="button"
>
Active ({activeCount})
</button>
<button
className={`pill ${filter === "completed" ? "active" : ""}`}
onClick={() => onChangeFilter("completed")}
type="button"
>
Completed ({completedCount})
</button>
</div>
<div className="muted">Tip: Keep tasks small and actionable.</div>
</div>
);
}
src/index.css
:root {
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
color: #111827;
background: #f3f4f6;
}
* { box-sizing: border-box; }
body {
margin: 0;
}
.container {
max-width: 760px;
margin: 40px auto;
padding: 0 16px;
}
.header {
margin-bottom: 16px;
}
.subtitle {
margin-top: 4px;
color: #4b5563;
}
.card {
background: white;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 16px;
box-shadow: 0 8px 24px rgba(0,0,0,0.06);
}
.footer {
margin-top: 16px;
color: #6b7280;
}
.row {
display: flex;
gap: 8px;
align-items: center;
}
input[type="text"] {
width: 100%;
padding: 10px 12px;
border: 1px solid #d1d5db;
border-radius: 10px;
font-size: 14px;
}
button {
padding: 10px 12px;
border: 1px solid #d1d5db;
border-radius: 10px;
background: #111827;
color: white;
cursor: pointer;
}
button.secondary {
background: white;
color: #111827;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.list {
margin-top: 12px;
padding: 0;
list-style: none;
}
.item {
display: flex;
gap: 10px;
align-items: center;
padding: 10px 8px;
border-bottom: 1px solid #f3f4f6;
}
.item:last-child {
border-bottom: none;
}
.title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.title.completed {
text-decoration: line-through;
color: #6b7280;
}
.muted {
color: #6b7280;
}
.pillbar {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 12px;
align-items: center;
justify-content: space-between;
}
.pills {
display: flex;
gap: 8px;
}
.pill {
padding: 6px 10px;
border-radius: 999px;
border: 1px solid #d1d5db;
background: white;
cursor: pointer;
}
.pill.active {
background: #111827;
color: white;
border-color: #111827;
}
Running the app
From the project root:
npm run dev
Build for production:
npm run build
npm run preview
At this point you have a functional To‑Do app demonstrating React state, hooks (useState, useEffect, useMemo), component composition, and full CRUD behavior with persistence.