Build a Todo App with React — From Scratch to Production-Ready
Build a complete todo app in React with hooks, localStorage persistence, filtering, drag-and-drop reordering, and keyboard shortcuts. No boilerplate — every line explained.
A todo app is the "Hello World" of frontend frameworks. That's exactly why most tutorials do it wrong — they stop at the toy version. You end up with a text input and an unordered list that forgets everything on refresh.
This tutorial builds a todo app that you'd actually use. Persistence with localStorage, filtering, bulk actions, keyboard shortcuts, drag-and-drop reordering, and proper component architecture. By the end, you'll have a project worth putting on a portfolio because it demonstrates real React patterns, not just useState with a list.
Project Setup
npx create-react-app todo-app
cd todo-app
Or if you prefer Vite (faster, recommended):
npm create vite@latest todo-app -- --template react
cd todo-app
npm install
Delete everything in src/ except main.jsx (or index.js) and App.jsx. Clean slate.
Data Model
Before writing any JSX, define what a todo looks like:
// src/types.js
/**
* @typedef {Object} Todo
* @property {string} id - Unique identifier
* @property {string} text - The todo content
* @property {boolean} completed - Whether it's done
* @property {number} createdAt - Timestamp
* @property {number} order - Sort position for drag-and-drop
*/
Most tutorials use the array index as the key. That breaks the moment you reorder, filter, or delete items. Always use a proper ID:
const generateId = () => crypto.randomUUID();
crypto.randomUUID() is built into every modern browser. No library needed.
The Custom Hook: useTodos
Separate your logic from your UI. This is the single most important React pattern and tutorials skip it constantly.
// src/hooks/useTodos.js
import { useState, useEffect, useCallback } from "react";
const STORAGE_KEY = "todo-app-items";
function loadTodos() {
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
}
function saveTodos(todos) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
}
export function useTodos() {
const [todos, setTodos] = useState(loadTodos);
const [filter, setFilter] = useState("all"); // "all" | "active" | "completed"
// Persist on every change
useEffect(() => {
saveTodos(todos);
}, [todos]);
const addTodo = useCallback((text) => {
const trimmed = text.trim();
if (!trimmed) return;
setTodos((prev) => [
...prev,
{
id: crypto.randomUUID(),
text: trimmed,
completed: false,
createdAt: Date.now(),
order: prev.length,
},
]);
}, []);
const toggleTodo = useCallback((id) => {
setTodos((prev) =>
prev.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
}, []);
const deleteTodo = useCallback((id) => {
setTodos((prev) => prev.filter((todo) => todo.id !== id));
}, []);
const editTodo = useCallback((id, newText) => {
const trimmed = newText.trim();
if (!trimmed) return;
setTodos((prev) =>
prev.map((todo) =>
todo.id === id ? { ...todo, text: trimmed } : todo
)
);
}, []);
const clearCompleted = useCallback(() => {
setTodos((prev) => prev.filter((todo) => !todo.completed));
}, []);
const toggleAll = useCallback(() => {
setTodos((prev) => {
const allCompleted = prev.every((t) => t.completed);
return prev.map((t) => ({ ...t, completed: !allCompleted }));
});
}, []);
const reorder = useCallback((fromIndex, toIndex) => {
setTodos((prev) => {
const result = [...prev];
const [removed] = result.splice(fromIndex, 1);
result.splice(toIndex, 0, removed);
return result.map((todo, i) => ({ ...todo, order: i }));
});
}, []);
const filteredTodos = todos.filter((todo) => {
if (filter === "active") return !todo.completed;
if (filter === "completed") return todo.completed;
return true;
});
const activeCount = todos.filter((t) => !t.completed).length;
const completedCount = todos.filter((t) => t.completed).length;
return {
todos: filteredTodos,
filter,
setFilter,
addTodo,
toggleTodo,
deleteTodo,
editTodo,
clearCompleted,
toggleAll,
reorder,
activeCount,
completedCount,
totalCount: todos.length,
};
}
Notice: the hook returns everything the UI needs. The components don't need to know about localStorage, filtering logic, or array manipulation. They just call functions and render data.
Components
TodoInput
// src/components/TodoInput.jsx
import { useState, useRef, useEffect } from "react";
export function TodoInput({ onAdd }) {
const [text, setText] = useState("");
const inputRef = useRef(null);
// Auto-focus on mount
useEffect(() => {
inputRef.current?.focus();
}, []);
const handleSubmit = (e) => {
e.preventDefault();
onAdd(text);
setText("");
};
return (
<form onSubmit={handleSubmit} className="todo-input-form">
<input
ref={inputRef}
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="What needs to be done?"
className="todo-input"
autoComplete="off"
/>
<button type="submit" disabled={!text.trim()}>
Add
</button>
</form>
);
}
TodoItem with Inline Editing
This is where most tutorials fall short. A real todo app needs inline editing — double-click to edit, Enter to save, Escape to cancel:
// src/components/TodoItem.jsx
import { useState, useRef, useEffect } from "react";
export function TodoItem({ todo, onToggle, onDelete, onEdit }) {
const [isEditing, setIsEditing] = useState(false);
const [editText, setEditText] = useState(todo.text);
const editRef = useRef(null);
useEffect(() => {
if (isEditing) {
editRef.current?.focus();
editRef.current?.select();
}
}, [isEditing]);
const handleDoubleClick = () => {
setIsEditing(true);
setEditText(todo.text);
};
const handleSave = () => {
const trimmed = editText.trim();
if (trimmed) {
onEdit(todo.id, trimmed);
}
setIsEditing(false);
};
const handleKeyDown = (e) => {
if (e.key === "Enter") handleSave();
if (e.key === "Escape") {
setEditText(todo.text);
setIsEditing(false);
}
};
if (isEditing) {
return (
<li className="todo-item editing">
<input
ref={editRef}
type="text"
value={editText}
onChange={(e) => setEditText(e.target.value)}
onBlur={handleSave}
onKeyDown={handleKeyDown}
className="edit-input"
/>
</li>
);
}
return (
<li className={todo-item ${todo.completed ? "completed" : ""}}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
className="todo-checkbox"
/>
<span className="todo-text" onDoubleClick={handleDoubleClick}>
{todo.text}
</span>
<button
onClick={() => onDelete(todo.id)}
className="delete-btn"
aria-label="Delete todo"
>
×
</button>
</li>
);
}
TodoFilter
// src/components/TodoFilter.jsx
export function TodoFilter({
filter,
setFilter,
activeCount,
completedCount,
onClearCompleted,
}) {
return (
<div className="todo-filters">
<span className="count">
{activeCount} item{activeCount !== 1 ? "s" : ""} left
</span>
<div className="filter-buttons">
{["all", "active", "completed"].map((f) => (
<button
key={f}
className={filter === f ? "active" : ""}
onClick={() => setFilter(f)}
>
{f.charAt(0).toUpperCase() + f.slice(1)}
</button>
))}
</div>
{completedCount > 0 && (
<button onClick={onClearCompleted} className="clear-btn">
Clear completed ({completedCount})
</button>
)}
</div>
);
}
Putting It Together: App.jsx
// src/App.jsx
import { useTodos } from "./hooks/useTodos";
import { TodoInput } from "./components/TodoInput";
import { TodoItem } from "./components/TodoItem";
import { TodoFilter } from "./components/TodoFilter";
import "./App.css";
function App() {
const {
todos,
filter,
setFilter,
addTodo,
toggleTodo,
deleteTodo,
editTodo,
clearCompleted,
toggleAll,
activeCount,
completedCount,
totalCount,
} = useTodos();
return (
<div className="app">
<h1>Todos</h1>
<TodoInput onAdd={addTodo} />
{totalCount > 0 && (
<>
<div className="toggle-all-container">
<label>
<input
type="checkbox"
checked={activeCount === 0}
onChange={toggleAll}
/>
Mark all as complete
</label>
</div>
<ul className="todo-list">
{todos.map((todo) => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={toggleTodo}
onDelete={deleteTodo}
onEdit={editTodo}
/>
))}
</ul>
<TodoFilter
filter={filter}
setFilter={setFilter}
activeCount={activeCount}
completedCount={completedCount}
onClearCompleted={clearCompleted}
/>
</>
)}
</div>
);
}
export default App;
Adding Keyboard Shortcuts
Global keyboard shortcuts make the app feel polished. This is a useEffect in App.jsx:
useEffect(() => {
const handleKeyDown = (e) => {
// Ctrl+Shift+A: Toggle all
if (e.ctrlKey && e.shiftKey && e.key === "A") {
e.preventDefault();
toggleAll();
}
// Ctrl+Shift+D: Clear completed
if (e.ctrlKey && e.shiftKey && e.key === "D") {
e.preventDefault();
clearCompleted();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [toggleAll, clearCompleted]);
Drag-and-Drop Reordering
No library needed for basic drag-and-drop. The HTML Drag and Drop API works fine:
// Updated TodoItem — add these props and handlers
export function TodoItem({ todo, index, onToggle, onDelete, onEdit, onReorder }) {
const [dragOver, setDragOver] = useState(false);
const handleDragStart = (e) => {
e.dataTransfer.setData("text/plain", index.toString());
e.currentTarget.classList.add("dragging");
};
const handleDragEnd = (e) => {
e.currentTarget.classList.remove("dragging");
};
const handleDragOver = (e) => {
e.preventDefault();
setDragOver(true);
};
const handleDragLeave = () => setDragOver(false);
const handleDrop = (e) => {
e.preventDefault();
setDragOver(false);
const fromIndex = parseInt(e.dataTransfer.getData("text/plain"), 10);
if (fromIndex !== index) {
onReorder(fromIndex, index);
}
};
return (
<li
draggable
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={todo-item ${todo.completed ? "completed" : ""} ${
dragOver ? "drag-over" : ""
}}
>
{/ ... rest of the component /}
</li>
);
}
Styling
Here's a minimal but clean CSS. Nothing fancy, just readable:
/ src/App.css /
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f5f5f5;
color: #333;
}
.app {
max-width: 560px;
margin: 60px auto;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
padding: 24px;
}
h1 {
text-align: center;
font-size: 2rem;
margin-bottom: 20px;
color: #e74c3c;
}
.todo-input-form {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.todo-input {
flex: 1;
padding: 10px 14px;
border: 2px solid #ddd;
border-radius: 6px;
font-size: 1rem;
transition: border-color 0.2s;
}
.todo-input:focus {
outline: none;
border-color: #e74c3c;
}
.todo-list {
list-style: none;
}
.todo-item {
display: flex;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #eee;
gap: 10px;
cursor: grab;
}
.todo-item.completed .todo-text {
text-decoration: line-through;
opacity: 0.5;
}
.todo-item.drag-over {
border-top: 2px solid #e74c3c;
}
.todo-text {
flex: 1;
cursor: pointer;
}
.delete-btn {
background: none;
border: none;
font-size: 1.3rem;
color: #ccc;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s;
}
.todo-item:hover .delete-btn {
opacity: 1;
color: #e74c3c;
}
.todo-filters {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 16px;
font-size: 0.85rem;
color: #888;
}
Common Mistakes
| Mistake | Why It's Bad | Fix |
|---|---|---|
| Using array index as key | Reordering/deletion causes wrong items to re-render | Use crypto.randomUUID() |
| State in every component | Prop drilling hell, duplicated logic | Extract to custom hook |
| No persistence | Refresh = everything gone | localStorage in useEffect |
| Mutation instead of immutable updates | React won't re-render | Always spread or map to new arrays |
| Missing keyboard support | Accessibility failure | Add Enter/Escape handlers |
| No loading state for localStorage | Flash of empty content | Use useState(loadTodos) (lazy init) |
What to Add Next
Once you've got this working, here are solid extensions that demonstrate more React skills:
- Categories/tags — adds relational data to the model
- Due dates — introduces date handling, sorting, overdue highlighting
- Search — debounced text filtering (good
useMemopractice) - Dark mode — CSS variables + context or media query
- Backend sync — replace localStorage with a REST API
The Takeaway
The todo app isn't about todos. It's about learning CRUD operations, state management, persistence, component composition, and user interaction patterns. Every one of those patterns shows up in production React apps, whether you're building a project management tool or a social media feed.
Build this, then immediately start something more complex. The todo app is a stepping stone, not a destination.