React Todo Application
Table of Contents + β
You just finished React Best Practices, so now letβs put all of it to work in one real app. Weβre going to build a complete todo app that adds, edits, toggles, deletes, filters, counts, and even remembers your tasks after a page refresh.
π― What Weβre Building
A todo app is the perfect first project. It touches almost every React idea youβve learned so far. By the end of this page youβll have a clean, working task manager that you fully understand line by line.
Hereβs everything our app will do:
- Add a new task with a controlled form
- Show all tasks in a list
- Mark a task complete with a checkbox (and show a line-through style)
- Edit a taskβs text right where it sits
- Delete a task
- Switch between filter tabs: All, Active, Completed
- Show a live count of how many tasks are still left
- Clear all completed tasks with one button
- Save everything to localStorage so tasks stay around after a refresh
Hereβs roughly what the finished screen looks like:
Output
βββββββββββββββββββββββββββββββββββββββββββββ β My Tasks ββ ββ [ Add a new task... ] [ Add ] ββ ββ ββββββββββββββββββββββββββββββββββββββ ββ β β Buy groceries β π β ββ β β Finish React project β π β β (this one shows a line-through)β β β Call the dentist β π β ββ ββββββββββββββββββββββββββββββββββββββ ββ ββ 2 tasks left [All] [Active] [Completed] ββ [Clear completed]βββββββββββββββββββββββββββββββββββββββββββββDonβt worry about every detail yet. Weβll build it one feature at a time, and each piece will make sense as we go.
ποΈ Project Setup
Letβs create a fresh React project with Vite. Vite is a build tool that gives you a running React app in seconds.
-
Create the project. Open your terminal and run this. It scaffolds a React app using JavaScript.
Terminal window npm create vite@latest todo-app -- --template react -
Go into the folder and install the packages.
Terminal window cd todo-appnpm install -
Start the dev server so you can see changes live in the browser.
Terminal window npm run devOpen the address it prints (usually
http://localhost:5173) and youβll see the Vite starter page.
Now letβs look at where our files will live. We only need to touch a few files inside src. Hereβs the layout weβre aiming for:
todo-app/βββ src/β βββ App.jsx β all of our app logic and UIβ βββ App.css β all of our stylesβ βββ main.jsx β the entry file (Vite made this for you)βββ index.htmlTo keep this project simple and easy to follow, weβll build the whole app inside App.jsx. That way you can read the entire thing top to bottom without jumping between files. Later, in the Extend It section, Iβll show you how to split it into smaller components.
Before we write anything, clear out the starter junk. Open src/App.jsx and delete everything in it. Open src/App.css and delete everything in it too. Weβre starting clean.
π Showing the Task List
Letβs start with the data. A task is just an object. It needs an id so React can tell tasks apart, the text the user typed, and a completed flag thatβs true or false.
Hereβs our first version of App.jsx. It holds a few tasks in state and shows them on screen.
import { useState } from "react";import "./App.css";
function App() { const [tasks, setTasks] = useState([ { id: 1, text: "Buy groceries", completed: false }, { id: 2, text: "Finish React project", completed: true }, { id: 3, text: "Call the dentist", completed: false }, ]);
return ( <div className="app"> <h1>β My Tasks</h1>
<ul className="task-list"> {tasks.map((task) => ( <li key={task.id} className="task-item"> <span className="task-text">{task.text}</span> </li> ))} </ul> </div> );}
export default App;Hereβs whatβs happening:
- We call
useStatewith a starting array of three tasks. It gives ustasks(the current list) andsetTasks(the only correct way to change it). - Each task object has
id,text, andcompleted. - We use
tasks.map(...)to turn each task object into an<li>element on screen. - The
key={task.id}part is required. React uses that key to track which list item is which, so it can update the list efficiently.
The reason we keep tasks in state instead of a plain variable is simple. When state changes, React re-draws the screen for us. A plain variable would change in memory but the screen would never update. State is what connects your data to what the user sees.
Save the file and you should see your three tasks listed in the browser.
β Adding a Task
Now letβs let the user add a task. We need a text input and an Add button. The input will be a controlled input, which means React state holds whatever is typed, and the input just shows that state.
Add a new piece of state for the input text, and a form above the list.
import { useState } from "react";import "./App.css";
function App() { const [tasks, setTasks] = useState([]); const [newTask, setNewTask] = useState("");
function handleAddTask(e) { e.preventDefault(); const text = newTask.trim(); if (text === "") return;
const task = { id: Date.now(), text: text, completed: false, };
setTasks([task, ...tasks]); setNewTask(""); }
return ( <div className="app"> <h1>β My Tasks</h1>
<form className="add-form" onSubmit={handleAddTask}> <input type="text" className="task-input" placeholder="Add a new task..." value={newTask} onChange={(e) => setNewTask(e.target.value)} /> <button type="submit" className="add-btn">Add</button> </form>
<ul className="task-list"> {tasks.map((task) => ( <li key={task.id} className="task-item"> <span className="task-text">{task.text}</span> </li> ))} </ul> </div> );}
export default App;Letβs walk through the new parts:
newTaskholds whatever is currently typed in the input. The inputβsvalue={newTask}andonChangekeep them in sync, so React is always in control of the text.- We wrapped the input and button in a
<form>withonSubmit={handleAddTask}. A form lets the user press Enter to add, not just click. e.preventDefault()stops the browser from reloading the page, which is what a form normally does when submitted.newTask.trim()removes spaces from the ends. If the result is empty, wereturnearly so we never add a blank task.- We build a new task object.
Date.now()gives a unique number we use as theid. setTasks([task, ...tasks])makes a brand new array with the new task first, followed by all the old ones. Then we clear the input withsetNewTask("").
Notice we did not push into the old array. We created a new array instead. This is the immutability rule.
Tip
In React you never change state directly. You always make a new value and pass it to the setter. The ...tasks spread copies the old items into a new array, so React sees a fresh array and knows to re-draw.
I also changed the starting tasks to an empty array [], since now the user can add their own.
β Marking a Task Complete
Each task should have a checkbox. Clicking it flips completed between true and false. When a task is complete, weβll add a class that shows a line through the text.
Add a toggle handler and wire up a checkbox in each list item.
function handleToggle(id) { setTasks( tasks.map((task) => task.id === id ? { ...task, completed: !task.completed } : task ) );}And update the list item to use it:
<li key={task.id} className={task.completed ? "task-item completed" : "task-item"}> <input type="checkbox" checked={task.completed} onChange={() => handleToggle(task.id)} /> <span className="task-text">{task.text}</span></li>Hereβs the idea:
handleToggletakes theidof the task that was clicked.- We
mapover every task. If a taskβsidmatches, we return a copy withcompletedflipped using!task.completed. Every other task is returned unchanged. { ...task, completed: !task.completed }copies the whole task, then overwrites just thecompletedfield. This keeps things immutable again.- The checkbox uses
checked={task.completed}so it always shows the real state. ItsonChangecalls our toggle. - On the
<li>, we add acompletedclass only when the task is done. The CSS for that class draws the line-through.
The key habit here is the conditional copy inside map. You match by id, change the one task, and leave the rest alone.
βοΈ Editing a Task Inline
Now letβs let people fix a typo without deleting and re-adding. Weβll edit the task text right where it sits. When you click the edit button, that one task turns into a text input. Press Enter or click away to save.
We need to track which task is being edited and what the edited text is.
const [editingId, setEditingId] = useState(null);const [editText, setEditText] = useState("");
function startEditing(task) { setEditingId(task.id); setEditText(task.text);}
function saveEdit(id) { const text = editText.trim(); if (text === "") { setEditingId(null); setEditText(""); return; }
setTasks( tasks.map((task) => task.id === id ? { ...task, text: text } : task ) ); setEditingId(null); setEditText("");}Now the list item shows either an input (if itβs being edited) or the normal text:
<li key={task.id} className={task.completed ? "task-item completed" : "task-item"}> <input type="checkbox" checked={task.completed} onChange={() => handleToggle(task.id)} />
{editingId === task.id ? ( <input type="text" className="edit-input" value={editText} autoFocus onChange={(e) => setEditText(e.target.value)} onBlur={() => saveEdit(task.id)} onKeyDown={(e) => { if (e.key === "Enter") saveEdit(task.id); }} /> ) : ( <span className="task-text" onDoubleClick={() => startEditing(task)}> {task.text} </span> )}
<div className="task-actions"> <button className="edit-btn" onClick={() => startEditing(task)}>β</button> </div></li>Hereβs the flow:
editingIdremembers which task is currently open for editing. When itβsnull, nothing is being edited.editTextholds the text as the user types the edit, just like our add input.startEditingsaves the taskβs id and copies its current text intoeditText.- In the list,
editingId === task.idis the test. If true, we show a text input for that one task. If false, we show the normal<span>. saveEditwrites the new text back into the matching task with the same copy-in-map pattern. Then it clearseditingIdso the input closes. If the box is empty when you save, we just close the editor instead of leaving you stuck with an open input you canβt get out of.- The edit input saves on
onBlur(clicking away) and on pressing Enter. TheautoFocusputs the cursor in the box right away.
One thing to know if you trace the code. Pressing Enter calls saveEdit, which sets editingId to null and removes the input. Removing the input can also fire onBlur, which calls saveEdit again. That second call is a safe no-op, because editing is already closed, so there is nothing to worry about.
Note the spelling: in React itβs onDoubleClick, written exactly like that. I added a double-click on the text as a handy shortcut to start editing.
ποΈ Deleting a Task
Deleting is the easiest one. We keep every task except the one whose id matches.
Add the handler:
function handleDelete(id) { setTasks(tasks.filter((task) => task.id !== id));}And add a delete button next to the edit button:
<div className="task-actions"> <button className="edit-btn" onClick={() => startEditing(task)}>β</button> <button className="delete-btn" onClick={() => handleDelete(task.id)}>π</button></div>Whatβs going on:
filterreturns a new array containing only the tasks that pass the test.- Our test is
task.id !== id, which means βkeep every task that is NOT the one weβre deletingβ. - The matching task gets left out of the new array, so it disappears from the screen.
Again, filter gives us a brand new array, so we never touch the old one. Thatβs the immutable way to remove an item.
π Filter Tabs: All, Active, Completed
People want to focus. Letβs add three tabs so they can see All tasks, only the Active (not done) ones, or only the Completed ones.
We store the current filter in state, then build a filtered list right before rendering.
const [filter, setFilter] = useState("all");
const filteredTasks = tasks.filter((task) => { if (filter === "active") return !task.completed; if (filter === "completed") return task.completed; return true; // "all"});Then we render the tab buttons and map over filteredTasks instead of tasks:
<div className="filters"> <button className={filter === "all" ? "filter-btn active" : "filter-btn"} onClick={() => setFilter("all")} > All </button> <button className={filter === "active" ? "filter-btn active" : "filter-btn"} onClick={() => setFilter("active")} > Active </button> <button className={filter === "completed" ? "filter-btn active" : "filter-btn"} onClick={() => setFilter("completed")} > Completed </button></div>Hereβs how the filtering works:
filteris a string:"all","active", or"completed". The tab buttons set it.filteredTasksis computed fresh on every render from the realtaskslist.- If the filter is
"active", we keep tasks wherecompletedis false. If"completed", we keep the done ones. Otherwise we keep everything. - The active tab gets an extra
activeclass so we can highlight it in CSS.
One important point: we keep the full tasks list in state and only filter a copy for display. We never throw away data when filtering. The hidden tasks are still there, just not shown.
So in your list, remember to map over filteredTasks, not tasks:
<ul className="task-list"> {filteredTasks.map((task) => ( /* ...list item... */ ))}</ul>π’ Remaining Count and Clear Completed
Two small but nice touches. A live count of how many tasks are still left, and a button to wipe out all the completed ones.
For the count, we just count tasks that arenβt completed:
const remaining = tasks.filter((task) => !task.completed).length;For clearing completed, we keep only the ones that are not done:
function clearCompleted() { setTasks(tasks.filter((task) => !task.completed));}Then show both in a footer bar under the tabs:
<div className="footer"> <span className="count"> {remaining} {remaining === 1 ? "task" : "tasks"} left </span> <button className="clear-btn" onClick={clearCompleted}> Clear completed </button></div>A few notes:
remainingis computed on every render, so itβs always correct and up to date. No extra state needed.- We use
remaining === 1 ? "task" : "tasks"so it reads naturally, like β1 task leftβ instead of β1 tasks leftβ. clearCompletedkeeps only the active tasks, dropping every completed one in a fresh array.
Things you can calculate from existing state, like the count, should be calculated, not stored. Keeping a separate count in state would just be one more thing to keep in sync, and it would drift out of date.
πΎ Saving to localStorage
Right now, refreshing the page wipes every task. Letβs fix that with localStorage, which is a small storage box in the browser that survives refreshes. Weβll save tasks whenever they change, and load them when the app starts.
We use useEffect to save, and a function passed to useState to load.
import { useState, useEffect } from "react";
// Load saved tasks once, when the app first runs.const [tasks, setTasks] = useState(() => { const saved = localStorage.getItem("tasks"); return saved ? JSON.parse(saved) : [];});
// Save tasks every time the list changes.useEffect(() => { localStorage.setItem("tasks", JSON.stringify(tasks));}, [tasks]);Hereβs the two-way street:
- localStorage can only hold strings. So we use
JSON.stringify(tasks)to turn our array into a string when saving, andJSON.parse(saved)to turn it back into an array when loading. - We pass a function to
useState, likeuseState(() => ...). React runs that function only once, on the first render. Thatβs the right place to read the saved data so we donβt read from storage on every render. - The
useEffectruns after render. Its dependency array is[tasks], which means βrun this again whenevertaskschangesβ. So every add, edit, toggle, or delete saves the new list automatically.
That dependency array is the whole trick. Because tasks is listed, the save runs at exactly the right times and no more.
Caution
Be careful with the key name. We use "tasks" for both getItem and setItem. If they donβt match, loading silently gets nothing. And if your saved data ever gets corrupted, clear it from your browserβs dev tools under Application, Local Storage.
π¨ Styling
Now letβs make it look like a real app instead of plain HTML. Put all of this in src/App.css. Iβll explain the important parts right after.
* { margin: 0; padding: 0; box-sizing: border-box;}
body { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); min-height: 100vh; display: flex; justify-content: center; padding: 40px 16px;}
.app { width: 100%; max-width: 480px; background: #ffffff; border-radius: 16px; box-shadow: 0 20px 50px rgba(0, 0, 0, 0.2); padding: 28px; height: fit-content;}
h1 { text-align: center; color: #4338ca; margin-bottom: 24px; font-size: 28px;}
.add-form { display: flex; gap: 8px; margin-bottom: 20px;}
.task-input { flex: 1; padding: 12px 14px; border: 2px solid #e5e7eb; border-radius: 10px; font-size: 15px; outline: none; transition: border-color 0.2s;}
.task-input:focus { border-color: #6366f1;}
.add-btn { padding: 12px 20px; border: none; border-radius: 10px; background: #6366f1; color: #fff; font-size: 15px; font-weight: 600; cursor: pointer; transition: background 0.2s;}
.add-btn:hover { background: #4f46e5;}
.task-list { list-style: none;}
.task-item { display: flex; align-items: center; gap: 12px; padding: 12px 10px; border-radius: 10px; border-bottom: 1px solid #f3f4f6; transition: background 0.15s;}
.task-item:hover { background: #f9fafb;}
.task-item input[type="checkbox"] { width: 18px; height: 18px; cursor: pointer; accent-color: #6366f1;}
.task-text { flex: 1; font-size: 15px; color: #1f2937; cursor: pointer;}
.task-item.completed .task-text { text-decoration: line-through; color: #9ca3af;}
.edit-input { flex: 1; padding: 8px 10px; border: 2px solid #6366f1; border-radius: 8px; font-size: 15px; outline: none;}
.task-actions { display: flex; gap: 6px;}
.edit-btn,.delete-btn { border: none; background: transparent; font-size: 16px; cursor: pointer; padding: 4px 6px; border-radius: 6px; transition: background 0.15s;}
.edit-btn:hover { background: #e0e7ff;}
.delete-btn:hover { background: #fee2e2;}
.filters { display: flex; gap: 6px; justify-content: center; margin: 20px 0 14px;}
.filter-btn { padding: 6px 14px; border: 1px solid #e5e7eb; background: #fff; border-radius: 8px; font-size: 14px; cursor: pointer; color: #6b7280; transition: all 0.15s;}
.filter-btn:hover { border-color: #6366f1;}
.filter-btn.active { background: #6366f1; color: #fff; border-color: #6366f1;}
.footer { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; border-top: 1px solid #f3f4f6; font-size: 14px; color: #6b7280;}
.clear-btn { border: none; background: transparent; color: #ef4444; font-size: 14px; cursor: pointer; font-weight: 500;}
.clear-btn:hover { text-decoration: underline;}The styling choices that matter most:
- The
bodyuses a purple gradient background, and the app sits in a white rounded card with a soft shadow. That card-on-color look instantly feels modern. box-sizing: border-boxon everything means padding never makes elements wider than you expect. It saves a lot of layout headaches.- The
.add-formusesdisplay: flexwithflex: 1on the input, so the input stretches and the button stays its natural size. .task-item.completed .task-textis the line-through rule. It only applies when the<li>has thecompletedclass, which is exactly when the task is done.accent-colorcolors the checkbox to match our theme without any custom checkbox HTML.- The active filter tab is filled purple, while the others are plain. That makes the current view obvious at a glance.
π§© The Complete Code
Hereβs the whole App.jsx in one place. This is everything we built, working together.
import { useState, useEffect } from "react";import "./App.css";
function App() { const [tasks, setTasks] = useState(() => { const saved = localStorage.getItem("tasks"); return saved ? JSON.parse(saved) : []; }); const [newTask, setNewTask] = useState(""); const [filter, setFilter] = useState("all"); const [editingId, setEditingId] = useState(null); const [editText, setEditText] = useState("");
useEffect(() => { localStorage.setItem("tasks", JSON.stringify(tasks)); }, [tasks]);
function handleAddTask(e) { e.preventDefault(); const text = newTask.trim(); if (text === "") return;
const task = { id: Date.now(), text: text, completed: false }; setTasks([task, ...tasks]); setNewTask(""); }
function handleToggle(id) { setTasks( tasks.map((task) => task.id === id ? { ...task, completed: !task.completed } : task ) ); }
function handleDelete(id) { setTasks(tasks.filter((task) => task.id !== id)); }
function startEditing(task) { setEditingId(task.id); setEditText(task.text); }
function saveEdit(id) { const text = editText.trim(); if (text === "") { setEditingId(null); setEditText(""); return; }
setTasks( tasks.map((task) => task.id === id ? { ...task, text: text } : task ) ); setEditingId(null); setEditText(""); }
function clearCompleted() { setTasks(tasks.filter((task) => !task.completed)); }
const filteredTasks = tasks.filter((task) => { if (filter === "active") return !task.completed; if (filter === "completed") return task.completed; return true; });
const remaining = tasks.filter((task) => !task.completed).length;
return ( <div className="app"> <h1>β My Tasks</h1>
<form className="add-form" onSubmit={handleAddTask}> <input type="text" className="task-input" placeholder="Add a new task..." value={newTask} onChange={(e) => setNewTask(e.target.value)} /> <button type="submit" className="add-btn">Add</button> </form>
<ul className="task-list"> {filteredTasks.map((task) => ( <li key={task.id} className={task.completed ? "task-item completed" : "task-item"} > <input type="checkbox" checked={task.completed} onChange={() => handleToggle(task.id)} />
{editingId === task.id ? ( <input type="text" className="edit-input" value={editText} autoFocus onChange={(e) => setEditText(e.target.value)} onBlur={() => saveEdit(task.id)} onKeyDown={(e) => { if (e.key === "Enter") saveEdit(task.id); }} /> ) : ( <span className="task-text" onDoubleClick={() => startEditing(task)} > {task.text} </span> )}
<div className="task-actions"> <button className="edit-btn" onClick={() => startEditing(task)} > β </button> <button className="delete-btn" onClick={() => handleDelete(task.id)} > π </button> </div> </li> ))} </ul>
<div className="filters"> <button className={filter === "all" ? "filter-btn active" : "filter-btn"} onClick={() => setFilter("all")} > All </button> <button className={filter === "active" ? "filter-btn active" : "filter-btn"} onClick={() => setFilter("active")} > Active </button> <button className={ filter === "completed" ? "filter-btn active" : "filter-btn" } onClick={() => setFilter("completed")} > Completed </button> </div>
<div className="footer"> <span className="count"> {remaining} {remaining === 1 ? "task" : "tasks"} left </span> <button className="clear-btn" onClick={clearCompleted}> Clear completed </button> </div> </div> );}
export default App;βΆοΈ Run It
If your dev server is still running, the page already updated itself. If not, start it again from the project folder.
npm run devOpen http://localhost:5173 in your browser. Type a task and press Enter. Check it off, edit it, delete it, switch tabs, and refresh the page to see your tasks survive.
Output
β My Tasks
[ Add a new task... ] [ Add ]
β Buy groceries β πβ Finish React project β π β line-throughβ Call the dentist β π
2 tasks left [All] [Active] [Completed] Clear completedβ οΈ Common Mistakes
A few traps that catch almost everyone the first time:
-
Changing state directly. Donβt push or edit the array in place.
// β Mutates the existing array, React may not re-drawtasks.push(newTask);setTasks(tasks);// β Make a new arraysetTasks([newTask, ...tasks]); -
Forgetting the key on list items. Without a stable
key, React gets confused when the list changes.// β No key{tasks.map((task) => <li>{task.text}</li>)}// β Key by a stable id{tasks.map((task) => <li key={task.id}>{task.text}</li>)} -
Not preventing the form reload. A submitted form reloads the page unless you stop it.
// β Page reloads, you lose everythingfunction handleAddTask(e) { /* no preventDefault */ }// β Stop the default behaviorfunction handleAddTask(e) { e.preventDefault(); } -
Filtering by deleting. Donβt remove tasks from state to filter the view. Keep all tasks, filter a copy for display.
-
Mismatched localStorage keys. If
getItemandsetItemuse different names, your tasks wonβt load.
β Best Practices
The habits this project quietly follows:
- Keep state immutable. Always build a new array or object with
map,filter, or the spread.... - Use a stable
idas thekey, never the array index, since the list reorders and changes. - Compute derived values like the count and the filtered list during render instead of storing them in state.
- Use controlled inputs so React state is the single source of truth for form fields.
- Give the
useEffecta correct dependency array so it saves at the right time and no more.
π Extend It / Make It Your Own
Once it works, try making it yours:
- Split into components. Move the list item into a
TaskItemcomponent and the form intoAddTask. Pass handlers down as props. - Add due dates. Add a
dueDatefield and sort tasks by it. - Add priorities. Let users tag a task high, medium, or low, and color the dot.
- Add a βMark all completeβ button. One click flips every task to done.
- Add search. A search box that filters tasks by text, on top of the tabs.
- Animate removals. Fade or slide a task out when itβs deleted.
π§© What Youβve Learned
Look at everything you can now do:
- β Build a full CRUD app: create, read, update, and delete tasks
- β Use controlled inputs for the add form and inline editing
- β
Update arrays of objects immutably with
map,filter, and spread - β Toggle a boolean field and style it with a conditional class
- β Derive a filtered list and a live count from state
- β
Persist data to localStorage and reload it with
useEffectand lazyuseState
Check Your Knowledge
Test what you learned. Pick an answer for each question, then click Check.
- 1
Why do we use setTasks([task, ...tasks]) instead of tasks.push(task)?
Why: React relies on getting a new array reference to detect a change. push mutates the old array in place, so React may not re-render. The spread creates a fresh array.
- 2
How does the app remember tasks after a page refresh?
Why: We stringify tasks into localStorage inside a useEffect with [tasks] as the dependency, and read them back once using a function passed to useState.
- 3
When the Active filter is selected, what happens to completed tasks?
Why: We never remove data when filtering. The full tasks list stays in state; we compute filteredTasks just for rendering.
- 4
Why is the remaining count calculated during render instead of stored in state?
Why: Derived values should be computed from existing state. Storing a separate count would be one more thing to keep in sync, and it could drift out of date.
π Whatβs Next?
Youβve built a complete app that manages local data. Next, letβs connect to the real world and pull live data from an API.