Building a Simple To-Do App with React (Beginner-Friendly Guide)
This tutorial walks you through building a small but real React to-do application from scratch. You will create a project, understand the React concepts involved, implement add/complete/delete functionality, persist tasks in localStorage, and structure your code in a clean, beginner-friendly way.
What You Will Build
A simple to-do app that can:
- Add a new task
- Mark tasks as completed (toggle)
- Delete tasks
- Filter tasks (All / Active / Completed)
- Persist tasks in the browser using
localStorage
You’ll also learn:
- How React components work
- How to use
useStateanduseEffect - How to handle forms and events
- How to render lists correctly with keys
- How to keep logic organized and predictable
Prerequisites
You should have:
- Basic JavaScript knowledge (variables, arrays, functions)
- Node.js installed (recommended: current LTS)
- A code editor (VS Code is common)
Check your Node and npm versions:
node -v
npm -v
If those commands fail, install Node.js from https://nodejs.org/.
Step 1: Create a New React Project
There are multiple ways to start a React app. A modern and fast option is Vite.
Create a new project:
npm create vite@latest todo-react -- --template react
Go into the folder and install dependencies:
cd todo-react
npm install
Start the development server:
npm run dev
Vite will print a local URL (often http://localhost:5173). Open it in your browser.
Step 2: Understand the Default Project Structure
Inside your project you’ll see something like:
index.html– the page Vite servessrc/main.jsx– the entry point that mounts Reactsrc/App.jsx– the main componentsrc/index.css– global stylespackage.json– scripts and dependencies
React works by rendering components (functions) into the DOM. Vite’s main.jsx typically looks like:
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
This means: “Render <App /> inside the element with id root.”
Step 3: Clean Up the Starter App
Open src/App.jsx and replace it with a minimal starting point:
import { useEffect, useMemo, useState } from "react";
import "./App.css";
export default function App() {
return (
<main className="app">
<h1>To-Do App</h1>
</main>
);
}
Create src/App.css (or edit it if it exists) and add some basic styling:
.app {
max-width: 720px;
margin: 48px auto;
padding: 0 16px;
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
}
h1 {
margin-bottom: 16px;
}
.card {
background: #ffffff;
border: 1px solid #e6e6e6;
border-radius: 12px;
padding: 16px;
}
.row {
display: flex;
gap: 8px;
}
.row > * {
flex: 1;
}
button {
flex: 0 0 auto;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid #d0d0d0;
background: #f7f7f7;
cursor: pointer;
}
button:hover {
background: #efefef;
}
input {
padding: 10px 12px;
border-radius: 10px;
border: 1px solid #d0d0d0;
}
.todo-list {
list-style: none;
padding: 0;
margin: 16px 0 0;
display: grid;
gap: 10px;
}
.todo-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px;
border: 1px solid #e6e6e6;
border-radius: 12px;
}
.todo-left {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.todo-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.todo-title.completed {
text-decoration: line-through;
color: #777;
}
.filters {
display: flex;
gap: 8px;
margin-top: 12px;
flex-wrap: wrap;
}
.filters button[aria-pressed="true"] {
border-color: #333;
background: #eaeaea;
}
.muted {
color: #666;
font-size: 14px;
}
Step 4: Plan the Data Model (What a “Todo” Looks Like)
Before writing logic, decide what a task object should contain. A good beginner-friendly shape:
{
id: "some-unique-id",
title: "Buy milk",
completed: false,
createdAt: 1700000000000
}
Why these fields?
id: React lists need stable keys. Using array index can cause bugs when deleting or reordering items.title: the text you display.completed: needed to render UI state and filter.createdAt: optional, but useful for sorting or debugging.
Step 5: Add State with useState
React state is how your UI “remembers” values over time. When state changes, React re-renders the component.
In src/App.jsx, add state for:
- the current input text (
title) - the list of todos (
todos) - the current filter (
filter)
Replace App.jsx with:
import { useEffect, useMemo, useState } from "react";
import "./App.css";
const FILTERS = ["all", "active", "completed"];
export default function App() {
const [title, setTitle] = useState("");
const [todos, setTodos] = useState([]);
const [filter, setFilter] = useState("all");
return (
<main className="app">
<h1>To-Do App</h1>
<section className="card">
<form className="row">
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Add a task..."
/>
<button type="submit">Add</button>
</form>
<div className="filters" role="group" aria-label="Filters">
{FILTERS.map((f) => (
<button
key={f}
type="button"
aria-pressed={filter === f}
onClick={() => setFilter(f)}
>
{f[0].toUpperCase() + f.slice(1)}
</button>
))}
</div>
<p className="muted">Tasks will appear here.</p>
</section>
</main>
);
}
What’s happening here?
useState("")sets the initial input value to an empty string.value={title}makes the input a controlled component (React is the source of truth).onChange={(e) => setTitle(e.target.value)}updates state as you type.- The filter buttons update the
filterstate.
At this point, the form doesn’t add tasks yet. Next we implement that.
Step 6: Handle Form Submission (Add a Todo)
In HTML, submitting a form reloads the page by default. In a React SPA, you usually prevent that and handle submission in JavaScript.
Add a helper to create unique IDs. For a beginner tutorial, crypto.randomUUID() is simple and built into modern browsers.
Update App.jsx:
import { useEffect, useMemo, useState } from "react";
import "./App.css";
const FILTERS = ["all", "active", "completed"];
function makeTodo(title) {
return {
id: crypto.randomUUID(),
title: title.trim(),
completed: false,
createdAt: Date.now(),
};
}
export default function App() {
const [title, setTitle] = useState("");
const [todos, setTodos] = useState([]);
const [filter, setFilter] = useState("all");
function handleSubmit(e) {
e.preventDefault();
const trimmed = title.trim();
if (!trimmed) return;
const newTodo = makeTodo(trimmed);
// Important: never mutate state arrays directly (no todos.push)
// Create a new array instead:
setTodos((prev) => [newTodo, ...prev]);
// Clear the input
setTitle("");
}
return (
<main className="app">
<h1>To-Do App</h1>
<section className="card">
<form className="row" onSubmit={handleSubmit}>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Add a task..."
/>
<button type="submit">Add</button>
</form>
<div className="filters" role="group" aria-label="Filters">
{FILTERS.map((f) => (
<button
key={f}
type="button"
aria-pressed={filter === f}
onClick={() => setFilter(f)}
>
{f[0].toUpperCase() + f.slice(1)}
</button>
))}
</div>
<p className="muted">
{todos.length === 0 ? "No tasks yet. Add one above." : "Your tasks:"}
</p>
</section>
</main>
);
}
Why not todos.push(newTodo)?
React state should be treated as immutable. If you mutate the existing array, React may not detect a change correctly, and you can create confusing bugs. Using [newTodo, ...prev] creates a brand-new array.
Step 7: Render the Todo List
Now we want to display tasks. In React you typically use map() to turn an array into elements.
Add this list under the “Your tasks” text:
<ul className="todo-list">
{todos.map((t) => (
<li key={t.id} className="todo-item">
<div className="todo-left">
<input type="checkbox" checked={t.completed} readOnly />
<span className={"todo-title" + (t.completed ? " completed" : "")}>
{t.title}
</span>
</div>
<button type="button">Delete</button>
</li>
))}
</ul>
Your App.jsx should now include that section. Full component so far:
import { useEffect, useMemo, useState } from "react";
import "./App.css";
const FILTERS = ["all", "active", "completed"];
function makeTodo(title) {
return {
id: crypto.randomUUID(),
title: title.trim(),
completed: false,
createdAt: Date.now(),
};
}
export default function App() {
const [title, setTitle] = useState("");
const [todos, setTodos] = useState([]);
const [filter, setFilter] = useState("all");
function handleSubmit(e) {
e.preventDefault();
const trimmed = title.trim();
if (!trimmed) return;
setTodos((prev) => [makeTodo(trimmed), ...prev]);
setTitle("");
}
return (
<main className="app">
<h1>To-Do App</h1>
<section className="card">
<form className="row" onSubmit={handleSubmit}>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Add a task..."
/>
<button type="submit">Add</button>
</form>
<div className="filters" role="group" aria-label="Filters">
{FILTERS.map((f) => (
<button
key={f}
type="button"
aria-pressed={filter === f}
onClick={() => setFilter(f)}
>
{f[0].toUpperCase() + f.slice(1)}
</button>
))}
</div>
<ul className="todo-list">
{todos.map((t) => (
<li key={t.id} className="todo-item">
<div className="todo-left">
<input type="checkbox" checked={t.completed} readOnly />
<span className={"todo-title" + (t.completed ? " completed" : "")}>
{t.title}
</span>
</div>
<button type="button">Delete</button>
</li>
))}
</ul>
{todos.length === 0 && (
<p className="muted">No tasks yet. Add one above.</p>
)}
</section>
</main>
);
}
The importance of key={t.id}
React uses keys to understand which item is which between renders. If you delete an item, React needs stable keys to avoid mixing up DOM elements and state. Using a unique id is the standard approach.
Step 8: Toggle Completion (Checkbox)
Right now the checkbox is read-only. Let’s make it interactive.
Add a function:
function toggleTodo(id) {
setTodos((prev) =>
prev.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t))
);
}
Why map() and { ...t, completed: !t.completed }?
map()creates a new array.- For the matching todo, we create a new object (immutability again).
- This ensures React sees a new reference and re-renders correctly.
Update the checkbox:
<input
type="checkbox"
checked={t.completed}
onChange={() => toggleTodo(t.id)}
/>
Now clicking the checkbox will toggle the completed state.
Step 9: Delete a Todo
Add a function:
function deleteTodo(id) {
setTodos((prev) => prev.filter((t) => t.id !== id));
}
Update the Delete button:
<button type="button" onClick={() => deleteTodo(t.id)}>
Delete
</button>
filter() returns a new array containing everything except the item to delete. Again: no mutation.
Step 10: Add Filtering (All / Active / Completed)
You already have filter buttons, but they don’t affect the list. We’ll compute a derived list based on todos and filter.
Derived data: useMemo
You can compute filtered todos directly in render, but useMemo teaches a valuable concept: “only recompute when inputs change.”
Add:
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]);
Then render visibleTodos instead of todos:
{visibleTodos.map((t) => (
...
))}
Why filtering is “derived state”
A common beginner mistake is to store both todos and filteredTodos in state. That duplicates data and can get out of sync. Instead, keep the source of truth (todos) and compute what you need (visibleTodos) from it.
Step 11: Persist Todos with localStorage
If you refresh the page now, your tasks disappear. To persist them, you can store them in localStorage.
How localStorage works
- It stores key/value pairs as strings.
- You usually use
JSON.stringify()to store arrays/objects. - You use
JSON.parse()to read them back.
We need two pieces:
- Load stored todos when the app starts.
- Save todos whenever they change.
1) Load on first render
A nice pattern is to use a lazy initializer for useState, so reading localStorage happens once:
const [todos, setTodos] = useState(() => {
const raw = localStorage.getItem("todos:v1");
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
});
This is better than reading localStorage at the top level because it avoids doing work on every render.
2) Save whenever todos changes
Use useEffect:
useEffect(() => {
localStorage.setItem("todos:v1", JSON.stringify(todos));
}, [todos]);
This effect runs after render whenever todos changes.
Step 12: Final App.jsx (Complete Working Version)
Replace src/App.jsx with the following complete code:
import { useEffect, useMemo, useState } from "react";
import "./App.css";
const FILTERS = ["all", "active", "completed"];
const STORAGE_KEY = "todos:v1";
function makeTodo(title) {
return {
id: crypto.randomUUID(),
title: title.trim(),
completed: false,
createdAt: Date.now(),
};
}
export default function App() {
const [title, setTitle] = useState("");
const [todos, setTodos] = useState(() => {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
});
const [filter, setFilter] = useState("all");
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
}, [todos]);
function handleSubmit(e) {
e.preventDefault();
const trimmed = title.trim();
if (!trimmed) return;
setTodos((prev) => [makeTodo(trimmed), ...prev]);
setTitle("");
}
function toggleTodo(id) {
setTodos((prev) =>
prev.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t))
);
}
function deleteTodo(id) {
setTodos((prev) => prev.filter((t) => t.id !== id));
}
function clearCompleted() {
setTodos((prev) => prev.filter((t) => !t.completed));
}
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]);
const remainingCount = useMemo(
() => todos.filter((t) => !t.completed).length,
[todos]
);
const completedCount = todos.length - remainingCount;
return (
<main className="app">
<h1>To-Do App</h1>
<section className="card">
<form className="row" onSubmit={handleSubmit}>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Add a task..."
aria-label="Task title"
/>
<button type="submit">Add</button>
</form>
<div className="filters" role="group" aria-label="Filters">
{FILTERS.map((f) => (
<button
key={f}
type="button"
aria-pressed={filter === f}
onClick={() => setFilter(f)}
>
{f[0].toUpperCase() + f.slice(1)}
</button>
))}
<button
type="button"
onClick={clearCompleted}
disabled={completedCount === 0}
title="Remove all completed tasks"
>
Clear completed
</button>
</div>
<p className="muted">
{todos.length === 0
? "No tasks yet. Add one above."
: `${remainingCount} remaining, ${completedCount} completed`}
</p>
<ul className="todo-list">
{visibleTodos.map((t) => (
<li key={t.id} className="todo-item">
<div className="todo-left">
<input
type="checkbox"
checked={t.completed}
onChange={() => toggleTodo(t.id)}
aria-label={t.completed ? "Mark as active" : "Mark as completed"}
/>
<span className={"todo-title" + (t.completed ? " completed" : "")}>
{t.title}
</span>
</div>
<button type="button" onClick={() => deleteTodo(t.id)}>
Delete
</button>
</li>
))}
</ul>
</section>
</main>
);
}
At this point you have a complete working to-do app.
Step 13: Test Your App (What to Try)
Try these actions:
- Add several tasks.
- Mark some completed.
- Switch filters:
- All: shows everything
- Active: shows only incomplete
- Completed: shows only complete
- Delete a task.
- Click “Clear completed”.
- Refresh the page and verify tasks persist.
If something doesn’t work:
- Open DevTools Console in your browser to see errors.
- Confirm you saved files and the dev server is still running.
Step 14: Common Beginner Mistakes (And How to Avoid Them)
1) Mutating state directly
Bad (don’t do this):
todos.push(newTodo);
setTodos(todos);
Good:
setTodos((prev) => [newTodo, ...prev]);
React relies on reference changes to know something changed. Mutation keeps the same reference and can cause stale UI.
2) Using array index as a key
Bad:
{todos.map((t, i) => <li key={i}>{t.title}</li>)}
If you delete the first item, every index shifts, and React can mismatch DOM nodes. Use a stable id.
3) Storing derived data in state
Avoid:
filteredTodosin stateremainingCountin state
Compute them from todos instead. This keeps your app consistent and easier to debug.
Step 15: Build for Production
When you’re ready to create a production build:
npm run build
To preview the production build locally:
npm run preview
Vite will serve the optimized build and show a local URL.
Ideas to Improve This App (Next Steps)
If you want to keep learning, here are realistic upgrades:
- Edit a task title
- Add an “Edit” button
- Swap the title for an input when editing
- Save on Enter or blur
- Better validation
- Prevent duplicate titles
- Show an error message instead of silently ignoring empty input
- Sorting
- Newest first (already)
- Oldest first
- Active tasks first
- Accessibility improvements
- Add focus styles
- Use more descriptive labels
- Component extraction
- Create
TodoItem.jsxandTodoFilters.jsxto practice splitting UI into components
- Create
Summary
You built a complete beginner-friendly React to-do app using core concepts:
useStatefor state- Controlled inputs for forms
- Immutable updates with
map()andfilter() - Rendering lists with stable keys
- Derived data with
useMemo - Persistence with
localStorageanduseEffect
If you want, tell me whether you’d like the next tutorial step to be editing tasks, drag-and-drop reordering, or moving to TypeScript—and I’ll extend this project accordingly.