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.
- 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 ReactDOMHere 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 truthfunction 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)} > × </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>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 buttonfunction 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 stateconst 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 tasksStep 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.jsxfunction 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)}> × </button> </li> );}
export default TodoItem;// FilterBar.jsxfunction 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.jsximport { 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;Sample Run
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.