React Todo Application

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.

  1. 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
  2. Go into the folder and install the packages.

    Terminal window
    cd todo-app
    npm install
  3. Start the dev server so you can see changes live in the browser.

    Terminal window
    npm run dev

    Open 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.html

To 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 useState with a starting array of three tasks. It gives us tasks (the current list) and setTasks (the only correct way to change it).
  • Each task object has id, text, and completed.
  • 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:

  • newTask holds whatever is currently typed in the input. The input’s value={newTask} and onChange keep them in sync, so React is always in control of the text.
  • We wrapped the input and button in a <form> with onSubmit={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, we return early so we never add a blank task.
  • We build a new task object. Date.now() gives a unique number we use as the id.
  • setTasks([task, ...tasks]) makes a brand new array with the new task first, followed by all the old ones. Then we clear the input with setNewTask("").

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:

  • handleToggle takes the id of the task that was clicked.
  • We map over every task. If a task’s id matches, we return a copy with completed flipped using !task.completed. Every other task is returned unchanged.
  • { ...task, completed: !task.completed } copies the whole task, then overwrites just the completed field. This keeps things immutable again.
  • The checkbox uses checked={task.completed} so it always shows the real state. Its onChange calls our toggle.
  • On the <li>, we add a completed class 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:

  • editingId remembers which task is currently open for editing. When it’s null, nothing is being edited.
  • editText holds the text as the user types the edit, just like our add input.
  • startEditing saves the task’s id and copies its current text into editText.
  • In the list, editingId === task.id is the test. If true, we show a text input for that one task. If false, we show the normal <span>.
  • saveEdit writes the new text back into the matching task with the same copy-in-map pattern. Then it clears editingId so 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. The autoFocus puts 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:

  • filter returns 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:

  • filter is a string: "all", "active", or "completed". The tab buttons set it.
  • filteredTasks is computed fresh on every render from the real tasks list.
  • If the filter is "active", we keep tasks where completed is false. If "completed", we keep the done ones. Otherwise we keep everything.
  • The active tab gets an extra active class 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:

  • remaining is 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”.
  • clearCompleted keeps 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, and JSON.parse(saved) to turn it back into an array when loading.
  • We pass a function to useState, like useState(() => ...). 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 useEffect runs after render. Its dependency array is [tasks], which means β€œrun this again whenever tasks changes”. 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 body uses 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-box on everything means padding never makes elements wider than you expect. It saves a lot of layout headaches.
  • The .add-form uses display: flex with flex: 1 on the input, so the input stretches and the button stays its natural size.
  • .task-item.completed .task-text is the line-through rule. It only applies when the <li> has the completed class, which is exactly when the task is done.
  • accent-color colors 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.

Terminal window
npm run dev

Open 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-draw
    tasks.push(newTask);
    setTasks(tasks);
    // βœ… Make a new array
    setTasks([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 everything
    function handleAddTask(e) { /* no preventDefault */ }
    // βœ… Stop the default behavior
    function 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 getItem and setItem use 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 id as the key, 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 useEffect a 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 TaskItem component and the form into AddTask. Pass handlers down as props.
  • Add due dates. Add a dueDate field 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 useEffect and lazy useState

Check Your Knowledge

Test what you learned. Pick an answer for each question, then click Check.

  1. 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. 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. 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. 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.

React Weather Application

Share & Connect