LearnAI ToolsCareerPractice BuildsPlayDevelopmentContact
React JSBeginner~1.5 hours

Todo App

Build a task manager with filtering and local storage persistence.

StatePropsHooks

Overview

A todo app is the classic first React project because it exercises the three ideas everything else in React builds on: state that a component owns and updates over time, props that pass data and behavior down to child components, and a useEffect hook that synchronizes that state with something outside React itself — here, localStorage. Nothing about it needs routing, a server, or global state, so it stays focused on those fundamentals.

By the end of this tutorial you will have a task manager where a single tasks array in the top-level App component is the one source of truth. A TodoItem component renders each row from props it receives, a FilterBar component switches between All/Active/Completed views, and a useEffect hook keeps every change saved to localStorage so the list survives a page refresh.

What You'll Build
  • A tasks array held in App with useState, updated immutably on every change.
  • A TodoItem component that receives a task and callbacks as props and renders one row.
  • A controlled form that adds a new task without reloading the page.
  • Click-to-toggle-complete and a delete button, both implemented as callback props passed down from App.
  • A FilterBar component for switching between All, Active, and Completed views.
  • Automatic saving to and loading from localStorage with useEffect, so tasks persist across reloads.

Prerequisites

  • JSX basics — writing markup inside a return statement and embedding expressions with {}.
  • Function components — a component is just a function that returns JSX.
  • Props — passing data and functions into a component as its argument, and reading them back out.
  • Solid core JavaScript — array methods (map, filter), spreading objects/arrays with ..., and arrow functions.
  • This is your first React project, so useState and useEffect are introduced here from scratch — you do not need to know them already.

Project Structure

The app is split into three components, each with one clear job. App is the only component that holds state — it owns the tasks array, the current filter, and every function that changes them. TodoItem and FilterBar hold no state of their own; they are purely props-driven, meaning everything they display or trigger comes in as a prop from App. Keeping state in exactly one place (a pattern called "lifting state up") is what guarantees the list, the filter buttons, and localStorage can never disagree about what the current tasks actually are.

src/
App.jsx // Owns tasks[] and filter state; the only component that changes them
TodoItem.jsx // One task row; purely props-driven, no state of its own
FilterBar.jsx // The All / Active / Completed buttons; purely props-driven
index.css // Shared dark-theme styling for the whole app
index.jsx // Mounts <App /> into the page with ReactDOM

Here is the shared stylesheet used by every component below. It does not change again after this — every remaining step only edits component files.

* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: Arial, Helvetica, sans-serif;
background: #1a1d23;
color: #e8e8e8;
display: flex;
justify-content: center;
padding: 40px 16px;
}
.todo-app {
width: 100%;
max-width: 420px;
background: #23262e;
border-radius: 10px;
padding: 24px;
}
.todo-app h1 {
margin-bottom: 16px;
font-size: 1.5rem;
}
.todo-form {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.todo-form input {
flex: 1; /* input grows to fill the space; the button keeps its natural width */
padding: 10px 12px;
border-radius: 6px;
border: 1px solid #3a3f4b;
background: #1a1d23;
color: #e8e8e8;
font-size: 1rem;
}
.todo-form button {
padding: 10px 16px;
border: none;
border-radius: 6px;
background: #4f8cff;
color: white;
cursor: pointer;
font-weight: bold;
}
.filter-bar {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.filter-btn {
flex: 1;
padding: 8px 0;
border: 1px solid #3a3f4b;
border-radius: 6px;
background: transparent;
color: #9aa4b2;
cursor: pointer;
}
.filter-btn.active {
background: #4f8cff;
color: white;
border-color: #4f8cff;
}
.todo-list {
list-style: none;
}
.todo-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 4px;
border-bottom: 1px solid #3a3f4b;
}
.todo-item.completed .todo-text {
text-decoration: line-through; /* visual cue that a task is finished */
opacity: 0.6;
}
.todo-text {
flex: 1;
cursor: pointer; /* clicking the text (not just a checkbox) toggles completion */
}
.delete-btn {
background: transparent;
border: none;
color: #ff6b6b;
cursor: pointer;
font-size: 1.1rem;
line-height: 1;
}
.empty-message {
text-align: center;
color: #888;
padding: 12px 0;
}

Step 1: Set Up State with useState

useState gives a function component memory that survives between renders. App needs three independent pieces of state: the tasks themselves, the text currently being typed into the "add task" input, and which filter is active — each gets its own useState call rather than being crammed into one object, since they change independently of each other.

import { useState } from 'react';
function App() {
// tasks is the single source of truth for every task in the list — every
// other piece of UI (the rendered rows, the filter counts) is derived FROM
// this array on every render, never stored as a separate copy
const [tasks, setTasks] = useState([]);
// What the user is currently typing in the "add task" input. Kept separate
// from tasks because draft text isn't a task yet — it only becomes one
// when the form is submitted
const [newTaskText, setNewTaskText] = useState('');
// 'all' | 'active' | 'completed' — a string is enough here, no need for a
// whole object just to track which filter button is selected
const [filter, setFilter] = useState('all');
return (
<div className="todo-app">
<h1>My Tasks</h1>
{/* Form, list, and filter bar are filled in over the next steps */}
</div>
);
}
export default App;

Every task will be a plain object shaped like { id, text, completed } — the same shape the plain-JavaScript version of this project would use, since React state is still just JavaScript data. React's job is only to keep the screen in sync with it.

Step 2: Render Tasks with a TodoItem Component

TodoItem is a separate component so App's render logic stays a simple .map() instead of a long block of row-building JSX. It receives a task object and two callback functions as props — it never touches tasks directly, because App is the only component allowed to change that state.

// A single row in the task list. It owns no state of its own — everything
// it needs to display or trigger arrives as a prop from its parent, which is
// what keeps App's tasks array as the one and only source of truth
function TodoItem({ task, onToggle, onDelete }) {
return (
<li className={'todo-item ' + (task.completed ? 'completed' : '')}>
{/* Clicking the text calls the callback passed down from App instead
of mutating task.completed directly — this component has no
access to setTasks and shouldn't need it */}
<span className="todo-text" onClick={() => onToggle(task.id)}>
{task.text}
</span>
<button
type="button"
className="delete-btn"
onClick={() => onDelete(task.id)}
>
&times;
</button>
</li>
);
}
export default TodoItem;

Back in App, tasks.map() turns the array into one <TodoItem> per task. The key prop is required on every item in a list — it is how React matches each <TodoItem> to the same underlying task across re-renders instead of throwing every row away and rebuilding it from scratch.

import { useState } from 'react';
import TodoItem from './TodoItem';
function App() {
const [tasks, setTasks] = useState([]);
const [newTaskText, setNewTaskText] = useState('');
const [filter, setFilter] = useState('all');
return (
<div className="todo-app">
<h1>My Tasks</h1>
{tasks.length === 0 && <p className="empty-message">No tasks yet. Add one below to get started!</p>}
<ul className="todo-list">
{tasks.map((task) => (
// key must be stable and unique per item, not the array index —
// an index would break if tasks are ever reordered or deleted
<TodoItem
key={task.id}
task={task}
onToggle={() => {}} // wired up for real in Step 4
onDelete={() => {}} // wired up for real in Step 4
/>
))}
</ul>
</div>
);
}
export default App;

Step 3: Add a New Task

The input is a "controlled" input — its value always comes from state, and every keystroke updates that state through onChange, so React state and the visible input can never drift apart. Submitting the form builds a new task object and adds it to tasks with setTasks.

function handleSubmit(event) {
event.preventDefault(); // stop the browser's default full-page reload on form submit
const trimmed = newTaskText.trim(); // trim() so a string of only spaces doesn't create a blank task
if (trimmed === '') {
return; // silently ignore empty submissions
}
const newTask = {
id: Date.now(), // a timestamp is unique enough for a client-only list; a real backend would assign this instead
text: trimmed,
completed: false,
};
// Never mutate tasks directly (e.g. tasks.push(newTask)) — React decides
// whether to re-render by comparing array REFERENCES, so a brand-new
// array has to be created for the update to be noticed
setTasks((prevTasks) => [...prevTasks, newTask]);
setNewTaskText(''); // clear the controlled input now that the task was added
}
// Inside the returned JSX, above the task list:
<form className="todo-form" onSubmit={handleSubmit}>
<input
type="text"
value={newTaskText} // controlled: React state is the only source of truth for what's shown
onChange={(event) => setNewTaskText(event.target.value)}
placeholder="What do you need to do?"
autoComplete="off"
/>
<button type="submit">Add</button>
</form>
What Happens in the tasks Array

Click Run to see what this code prints.

Step 4: Toggle and Delete Tasks

Both toggleTask and deleteTask live in App, right next to tasks, and get passed down to every TodoItem as the onToggle/onDelete props. Neither function ever mutates an existing task object or the array in place — each returns a brand-new array so React reliably detects the change.

function toggleTask(id) {
// map() returns a new array; only the matching task gets a new object —
// every other task's object reference is reused as-is, since it didn't change
setTasks((prevTasks) =>
prevTasks.map((task) =>
task.id === id ? { ...task, completed: !task.completed } : task
)
);
}
function deleteTask(id) {
// filter() also returns a new array — the deleted task simply isn't in it
setTasks((prevTasks) => prevTasks.filter((task) => task.id !== id));
}
// Now the real callbacks replace the empty placeholders from Step 2:
<TodoItem
key={task.id}
task={task}
onToggle={toggleTask}
onDelete={deleteTask}
/>

Step 5: Filter Tasks by Status

filteredTasks is calculated fresh on every render from tasks and filter — it is never stored in its own useState. Deriving it instead of duplicating it means the filtered view can never fall out of sync with the real task list, which would be an easy bug to introduce if it were tracked as separate state.

// One small, purely props-driven component per filter button
function FilterBar({ filter, onFilterChange }) {
const filters = ['all', 'active', 'completed'];
return (
<div className="filter-bar">
{filters.map((f) => (
<button
key={f}
type="button"
className={'filter-btn ' + (filter === f ? 'active' : '')}
onClick={() => onFilterChange(f)}
>
{f.charAt(0).toUpperCase() + f.slice(1)}
</button>
))}
</div>
);
}
export default FilterBar;
// Back in App, computed once per render, right before the JSX that uses it —
// derived data, not its own piece of state
const filteredTasks = tasks.filter((task) => {
if (filter === 'active') return !task.completed;
if (filter === 'completed') return task.completed;
return true; // filter === 'all'
});
// <FilterBar filter={filter} onFilterChange={setFilter} /> — setFilter is
// passed directly as the callback since useState's setter already accepts
// the new value as its only argument, no wrapper function needed
// <ul className="todo-list"> then maps over filteredTasks instead of tasks

Step 6: Persist Tasks with useEffect

Two separate mechanisms handle loading and saving. Loading uses useState's lazy initializer — a function passed to useState that runs exactly once, on the very first render, which avoids a flash of an empty list before localStorage is read. Saving uses useEffect with a [tasks] dependency array, so it re-runs only on renders where tasks actually changed, not on every render (like the ones caused by typing in the input).

import { useState, useEffect } from 'react';
const STORAGE_KEY = 'react-todo-app-tasks';
function App() {
// The function passed here only runs ONCE, on mount — reading
// localStorage in a lazy initializer (rather than in a useEffect that
// runs AFTER the first render) means tasks starts correct immediately,
// with no visible flash of an empty list first
const [tasks, setTasks] = useState(() => {
const saved = localStorage.getItem(STORAGE_KEY);
return saved ? JSON.parse(saved) : [];
});
const [newTaskText, setNewTaskText] = useState('');
const [filter, setFilter] = useState('all');
// Runs after every render where tasks changed since the previous render.
// The [tasks] dependency array is what limits it to those renders instead
// of saving on every keystroke in the unrelated newTaskText input
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
}, [tasks]);
// ...rest of the component from the previous steps
}

setTasks itself is intentionally left out of the dependency array — React guarantees the setter function returned by useState never changes identity between renders, so including it would add noise without changing when the effect runs.

Complete Code

Here is the complete project — the stylesheet and every component file, fully assembled from the steps above.

* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: Arial, Helvetica, sans-serif;
background: #1a1d23;
color: #e8e8e8;
display: flex;
justify-content: center;
padding: 40px 16px;
}
.todo-app { width: 100%; max-width: 420px; background: #23262e; border-radius: 10px; padding: 24px; }
.todo-app h1 { margin-bottom: 16px; font-size: 1.5rem; }
.todo-form { display: flex; gap: 8px; margin-bottom: 16px; }
.todo-form input { flex: 1; padding: 10px 12px; border-radius: 6px; border: 1px solid #3a3f4b; background: #1a1d23; color: #e8e8e8; font-size: 1rem; }
.todo-form button { padding: 10px 16px; border: none; border-radius: 6px; background: #4f8cff; color: white; cursor: pointer; font-weight: bold; }
.filter-bar { display: flex; gap: 8px; margin-bottom: 16px; }
.filter-btn { flex: 1; padding: 8px 0; border: 1px solid #3a3f4b; border-radius: 6px; background: transparent; color: #9aa4b2; cursor: pointer; }
.filter-btn.active { background: #4f8cff; color: white; border-color: #4f8cff; }
.todo-list { list-style: none; }
.todo-item { display: flex; align-items: center; gap: 10px; padding: 10px 4px; border-bottom: 1px solid #3a3f4b; }
.todo-item.completed .todo-text { text-decoration: line-through; opacity: 0.6; }
.todo-text { flex: 1; cursor: pointer; }
.delete-btn { background: transparent; border: none; color: #ff6b6b; cursor: pointer; font-size: 1.1rem; line-height: 1; }
.empty-message { text-align: center; color: #888; padding: 12px 0; }
// TodoItem.jsx
function TodoItem({ task, onToggle, onDelete }) {
return (
<li className={'todo-item ' + (task.completed ? 'completed' : '')}>
<span className="todo-text" onClick={() => onToggle(task.id)}>
{task.text}
</span>
<button type="button" className="delete-btn" onClick={() => onDelete(task.id)}>
&times;
</button>
</li>
);
}
export default TodoItem;
// FilterBar.jsx
function FilterBar({ filter, onFilterChange }) {
const filters = ['all', 'active', 'completed'];
return (
<div className="filter-bar">
{filters.map((f) => (
<button
key={f}
type="button"
className={'filter-btn ' + (filter === f ? 'active' : '')}
onClick={() => onFilterChange(f)}
>
{f.charAt(0).toUpperCase() + f.slice(1)}
</button>
))}
</div>
);
}
export default FilterBar;
// App.jsx
import { useState, useEffect } from 'react';
import TodoItem from './TodoItem';
import FilterBar from './FilterBar';
const STORAGE_KEY = 'react-todo-app-tasks';
function App() {
const [tasks, setTasks] = useState(() => {
const saved = localStorage.getItem(STORAGE_KEY);
return saved ? JSON.parse(saved) : [];
});
const [newTaskText, setNewTaskText] = useState('');
const [filter, setFilter] = useState('all');
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
}, [tasks]);
function handleSubmit(event) {
event.preventDefault();
const trimmed = newTaskText.trim();
if (trimmed === '') return;
setTasks((prevTasks) => [
...prevTasks,
{ id: Date.now(), text: trimmed, completed: false },
]);
setNewTaskText('');
}
function toggleTask(id) {
setTasks((prevTasks) =>
prevTasks.map((task) => (task.id === id ? { ...task, completed: !task.completed } : task))
);
}
function deleteTask(id) {
setTasks((prevTasks) => prevTasks.filter((task) => task.id !== id));
}
const filteredTasks = tasks.filter((task) => {
if (filter === 'active') return !task.completed;
if (filter === 'completed') return task.completed;
return true;
});
return (
<div className="todo-app">
<h1>My Tasks</h1>
<form className="todo-form" onSubmit={handleSubmit}>
<input
type="text"
value={newTaskText}
onChange={(event) => setNewTaskText(event.target.value)}
placeholder="What do you need to do?"
autoComplete="off"
/>
<button type="submit">Add</button>
</form>
<FilterBar filter={filter} onFilterChange={setFilter} />
{filteredTasks.length === 0 && (
<p className="empty-message">No tasks to show.</p>
)}
<ul className="todo-list">
{filteredTasks.map((task) => (
<TodoItem key={task.id} task={task} onToggle={toggleTask} onDelete={deleteTask} />
))}
</ul>
</div>
);
}
export default App;
Live Preview

Sample Run

Sample Interaction

Click Run to see what this code prints.

Extend This Project

  • Add an "editing" state to TodoItem so double-clicking a task turns its text into an <input>, saved on blur or Enter.
  • Add a "Clear Completed" button in App that filters every completed task out of tasks in one click.
  • Add a due date with <input type="date"> in the form, stored as another field on each task object.
  • Show a live count of remaining active tasks next to the filter bar, computed the same derived way as filteredTasks.
  • Extract the localStorage load/save logic into a reusable useLocalStorage custom hook so other projects can share it.

Summary

You built a complete, persistent Todo App using useState for state, props to pass data and callbacks down to child components, and useEffect to synchronize that state with localStorage. The core pattern — state lives in one place, gets passed down as props, and changes flow back up through callbacks — is the foundation for every React project you build next, including the other three in this course.