Time to put everything together. In this project you build a task manager: add tasks, tick them off, edit and delete them, filter them, clear the finished ones, and keep them when the page is reloaded. It is small enough to finish in one sitting and uses most of the course: components and props, state and reducers, controlled inputs, effects, accessibility and tests. I built and tested the version in this lesson in a fresh Vite project: the five tests pass, the linter reports nothing, and the production build is about 70 kB of JavaScript after compression.
What we are building
The finished app has:
- a form to add a task,
- a list where each task has a checkbox, an Edit button and a Delete button,
- filter buttons: all, active, done,
- a status line: "1 of 2 tasks left",
- a "Clear completed" button,
- tasks saved in the browser, so they are still there tomorrow.
Plan the pieces before writing code. Draw boxes (as in the composing lesson) and decide where state lives:
App tasks (reducer), filter
├── TaskForm draft text
├── Filters
├── status line
├── TaskList
│ └── TaskItem editing, draft text of the edit box
└── Clear completed buttonRules from earlier lessons guide the choices. The task list is needed by the form (to add), the list (to show) and the status line (to count), so it lives in their parent, App. The filter choice also lives in App. The text being typed in the form, and whether one item is in edit mode, concern only one component each, so they stay local. The visible list and the count are calculated from tasks and filter, never stored.
Step 1: create the project
npm create vite@latest task-manager -- --template react
cd task-manager
npm install
npm install -D vitest jsdom @testing-library/react @testing-library/user-event @testing-library/jest-dom
npm run devReplace src/index.css with something small (body { font-family: system-ui, sans-serif; max-width: 480px; margin: 2rem auto; }), delete src/App.css and the template's demo content, and create the folders src/components and src/hooks as needed.
Step 2: the reducer
Put all the rules for changing tasks in one pure function, src/tasksReducer.js (see the reducer lesson). Everything is an immutable update, and the ids come from the caller, so the reducer stays predictable:
export const initialTasks = []
export function tasksReducer(tasks, action) {
switch (action.type) {
case 'added':
return [...tasks, { id: action.id, text: action.text, done: false }]
case 'toggled':
return tasks.map(task => (task.id === action.id ? { ...task, done: !task.done } : task))
case 'edited':
return tasks.map(task => (task.id === action.id ? { ...task, text: action.text } : task))
case 'removed':
return tasks.filter(task => task.id !== action.id)
case 'cleared':
return tasks.filter(task => !task.done)
default:
throw new Error('Unknown action: ' + action.type)
}
}Step 3: the form
src/components/TaskForm.jsx owns the text being typed. It validates (ignores empty text), calls onAdd and clears itself. The label is linked to the input for accessibility:
import { useState } from 'react'
export default function TaskForm({ onAdd }) {
const [text, setText] = useState('')
function handleSubmit(event) {
event.preventDefault()
const trimmed = text.trim()
if (trimmed === '') return
onAdd(trimmed)
setText('')
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="new-task">New task</label>{' '}
<input id="new-task" value={text} onChange={e => setText(e.target.value)} />{' '}
<button>Add</button>
</form>
)
}Step 4: one task, with editing
src/components/TaskItem.jsx shows a task, or an edit box when the user presses Edit. The editing flag and the draft text are local state. The checkbox is wrapped in a label, so it is named after the task; the Edit and Delete buttons have aria-label values that include the task text, so a screen reader user hears "Delete Buy milk" rather than just "Delete":
import { useState } from 'react'
export default function TaskItem({ task, onToggle, onEdit, onRemove }) {
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState(task.text)
function save() {
const trimmed = draft.trim()
if (trimmed !== '') onEdit(task.id, trimmed)
setEditing(false)
}
if (editing) {
return (
<li>
<input aria-label="Edit task" value={draft} onChange={e => setDraft(e.target.value)} />{' '}
<button onClick={save}>Save</button>{' '}
<button onClick={() => { setDraft(task.text); setEditing(false) }}>Cancel</button>
</li>
)
}
return (
<li>
<label>
<input type="checkbox" checked={task.done} onChange={() => onToggle(task.id)} />{' '}
<span style={{ textDecoration: task.done ? 'line-through' : 'none' }}>{task.text}</span>
</label>{' '}
<button onClick={() => setEditing(true)} aria-label={'Edit ' + task.text}>Edit</button>{' '}
<button onClick={() => onRemove(task.id)} aria-label={'Delete ' + task.text}>Delete</button>
</li>
)
}Step 5: the list and the filters
src/components/TaskList.jsx maps the tasks to items with a stable key (the id), shows a message for an empty list, and forwards the handlers with ...handlers:
import TaskItem from './TaskItem.jsx'
export default function TaskList({ tasks, ...handlers }) {
if (tasks.length === 0) return <p>Nothing to show.</p>
return (
<ul style={{ listStyle: 'none', padding: 0 }}>
{tasks.map(task => (
<TaskItem key={task.id} task={task} {...handlers} />
))}
</ul>
)
}src/components/Filters.jsx is a controlled component: it gets the current filter and an onChange callback. aria-pressed tells assistive technology which button is active:
const FILTERS = ['all', 'active', 'done']
export default function Filters({ value, onChange }) {
return (
<div role="group" aria-label="Filter tasks">
{FILTERS.map(name => (
<button key={name} onClick={() => onChange(name)} aria-pressed={value === name}>
{name}
</button>
))}
</div>
)
}Step 6: App, persistence and derived values
src/App.jsx brings it together. Three ideas to notice: useReducer is given a loader function as the third argument so that saved tasks are read once at start-up (lazy initialisation), an effect writes the tasks back whenever they change (synchronising with localStorage, an outside system, which is what effects are for), and the visible list and remaining count are computed on each render:
import { useEffect, useReducer, useState } from 'react'
import { tasksReducer } from './tasksReducer.js'
import TaskForm from './components/TaskForm.jsx'
import TaskList from './components/TaskList.jsx'
import Filters from './components/Filters.jsx'
const STORAGE_KEY = 'tasks-v1'
function loadTasks() {
try {
const saved = window.localStorage.getItem(STORAGE_KEY)
return saved ? JSON.parse(saved) : []
} catch {
return []
}
}
export default function App() {
const [tasks, dispatch] = useReducer(tasksReducer, undefined, loadTasks)
const [filter, setFilter] = useState('all')
useEffect(() => {
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks))
} catch {
// ignore: storage may be unavailable
}
}, [tasks])
const visible = tasks.filter(task => filter === 'all' || (filter === 'done' ? task.done : !task.done))
const remaining = tasks.filter(task => !task.done).length
return (
<main>
<h1>Task manager</h1>
<TaskForm onAdd={text => dispatch({ type: 'added', id: crypto.randomUUID(), text })} />
<Filters value={filter} onChange={setFilter} />
<p role="status">{remaining} of {tasks.length} tasks left</p>
<TaskList
tasks={visible}
onToggle={id => dispatch({ type: 'toggled', id })}
onEdit={(id, text) => dispatch({ type: 'edited', id, text })}
onRemove={id => dispatch({ type: 'removed', id })}
/>
<button onClick={() => dispatch({ type: 'cleared' })} disabled={tasks.every(task => !task.done)}>
Clear completed
</button>
</main>
)
}The ids come from crypto.randomUUID(), called in the event handler (not in the reducer), so the reducer remains pure. The role="status" line is a live region, so screen readers announce "1 of 2 tasks left" after a change. main.jsx is the template's unchanged: it renders <App /> inside StrictMode.
Try the app in the editor
This single-file version contains the same logic, with two changes needed for the lesson editor: ids come from a counter, and tasks are kept in memory (the editor's sandbox blocks localStorage). Add a few tasks, tick some, change the filter and edit one:
import { useReducer, useState } from "react";
let nextId = 1;
function tasksReducer(tasks, action) {
switch (action.type) {
case "added":
return [...tasks, { id: action.id, text: action.text, done: false }];
case "toggled":
return tasks.map(task => (task.id === action.id ? { ...task, done: !task.done } : task));
case "removed":
return tasks.filter(task => task.id !== action.id);
case "cleared":
return tasks.filter(task => !task.done);
default:
throw new Error("Unknown action: " + action.type);
}
}
function TaskForm({ onAdd }) {
const [text, setText] = useState("");
function handleSubmit(event) {
event.preventDefault();
if (text.trim() === "") return;
onAdd(text.trim());
setText("");
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="new-task">New task</label>{" "}
<input id="new-task" value={text} onChange={e => setText(e.target.value)} />{" "}
<button>Add</button>
</form>
);
}
function App() {
const [tasks, dispatch] = useReducer(tasksReducer, []);
const [filter, setFilter] = useState("all");
const visible = tasks.filter(task => filter === "all" || (filter === "done" ? task.done : !task.done));
const remaining = tasks.filter(task => !task.done).length;
return (
<main>
<h1>Task manager</h1>
<TaskForm onAdd={text => dispatch({ type: "added", id: nextId++, text })} />
<div role="group" aria-label="Filter tasks">
{["all", "active", "done"].map(name => (
<button key={name} onClick={() => setFilter(name)} aria-pressed={filter === name}>{name}</button>
))}
</div>
<p role="status">{remaining} of {tasks.length} tasks left</p>
{visible.length === 0 ? <p>Nothing to show.</p> : (
<ul style={{ listStyle: "none", padding: 0 }}>
{visible.map(task => (
<li key={task.id}>
<label>
<input type="checkbox" checked={task.done} onChange={() => dispatch({ type: "toggled", id: task.id })} />{" "}
<span style={{ textDecoration: task.done ? "line-through" : "none" }}>{task.text}</span>
</label>{" "}
<button onClick={() => dispatch({ type: "removed", id: task.id })} aria-label={"Delete " + task.text}>Delete</button>
</li>
))}
</ul>
)}
<button onClick={() => dispatch({ type: "cleared" })} disabled={tasks.every(task => !task.done)}>
Clear completed
</button>
</main>
);
}
export default App;Task manager
New task Add
allactivedone
1 of 2 tasks left
Buy milk Delete
Clear completedIn the test I added two tasks, ticked the first and switched to the done filter, so only "Buy milk" is listed while the status line still counts both tasks.
Step 7: tests
Write tests for what a user does, in src/App.test.jsx. A small helper keeps them readable, beforeEach clears the storage so tests do not affect each other, and the last test unmounts the app and renders it again to prove that the tasks were saved:
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import App from './App.jsx'
beforeEach(() => {
window.localStorage.clear()
})
async function addTask(user, text) {
await user.type(screen.getByLabelText('New task'), text)
await user.click(screen.getByRole('button', { name: 'Add' }))
}
test('starts empty', () => {
render(<App />)
expect(screen.getByRole('status')).toHaveTextContent('0 of 0 tasks left')
expect(screen.getByText('Nothing to show.')).toBeInTheDocument()
})
test('adds, completes and clears tasks', async () => {
const user = userEvent.setup()
render(<App />)
await addTask(user, 'Buy milk')
await addTask(user, 'Call Ravi')
expect(screen.getAllByRole('listitem')).toHaveLength(2)
expect(screen.getByRole('status')).toHaveTextContent('2 of 2 tasks left')
await user.click(screen.getByRole('checkbox', { name: 'Buy milk' }))
expect(screen.getByRole('status')).toHaveTextContent('1 of 2 tasks left')
await user.click(screen.getByRole('button', { name: 'Clear completed' }))
expect(screen.queryByText('Buy milk')).not.toBeInTheDocument()
expect(screen.getByText('Call Ravi')).toBeInTheDocument()
})
test('filters the list', async () => {
const user = userEvent.setup()
render(<App />)
await addTask(user, 'One')
await addTask(user, 'Two')
await user.click(screen.getByRole('checkbox', { name: 'One' }))
await user.click(screen.getByRole('button', { name: 'done' }))
expect(screen.getAllByRole('listitem')).toHaveLength(1)
expect(screen.getByText('One')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'active' }))
expect(screen.getByText('Two')).toBeInTheDocument()
expect(screen.queryByText('One')).not.toBeInTheDocument()
})
test('edits a task', async () => {
const user = userEvent.setup()
render(<App />)
await addTask(user, 'Draft')
await user.click(screen.getByRole('button', { name: 'Edit Draft' }))
const box = screen.getByLabelText('Edit task')
await user.clear(box)
await user.type(box, 'Final')
await user.click(screen.getByRole('button', { name: 'Save' }))
const item = screen.getByRole('listitem')
expect(within(item).getByText('Final')).toBeInTheDocument()
})
test('keeps tasks after a reload', async () => {
const user = userEvent.setup()
const first = render(<App />)
await addTask(user, 'Remember me')
first.unmount()
render(<App />)
expect(screen.getByText('Remember me')).toBeInTheDocument()
})Running npx vitest run printed:
✓ src/App.test.jsx > starts empty
✓ src/App.test.jsx > adds, completes and clears tasks
✓ src/App.test.jsx > filters the list
✓ src/App.test.jsx > edits a task
✓ src/App.test.jsx > keeps tasks after a reload
Test Files 1 passed (1)
Tests 5 passed (5)Notice how the queries double as accessibility checks: getByRole('checkbox', { name: 'Buy milk' }) works because the label wraps the checkbox, and getByRole('button', { name: 'Edit Draft' }) works because of the aria-label.
Step 8: lint and build
npm run lint
npm run buildLinting printed nothing (no problems), and the build produced:
dist/index.html 0.45 kB │ gzip: 0.29 kB
dist/assets/index-UzODDjWK.css 0.07 kB │ gzip: 0.09 kB
dist/assets/index-CDC79Fyj.js 222.31 kB │ gzip: 69.59 kB
✓ built in 69msUpload the contents of dist to any static host. A single page with no router needs no rewrite rule; follow the building and deploying lesson for hosting options.
Ideas to extend it
Each of these practises a specific skill from the course:
- Due dates and sorting. Add a date to each task and sort by it (derived data, immutable sort).
- Priorities with a
select. Another controlled input and a new field in the reducer. - Move the state into context with
TasksProvider,useTasksanduseTasksDispatch, so that the list and form need no props. - Replace the manual persistence with the
useLocalStoragehook from the custom hooks lesson (try it withuseReducertoo). - Optimistic server sync. Replace
localStoragewith a fake API and useuseOptimistic. - Routing. Add
/activeand/doneroutes instead of a filter state, so that filters are in the URL. - Keyboard shortcuts and focus. Return focus to the new-task input after adding.
- Animations and a dark theme using CSS variables.
- Deploy it and send the link to a friend.
Common mistakes in projects like this
- Keeping both
tasksandvisibleTasksin state. - Generating ids in the reducer or during render, which breaks purity.
- Using the array index as the
keyfor tasks that can be removed or reordered. - Forgetting
event.preventDefault()in the form. - Reading
localStoragedirectly during render on every render; read once, in the initial state function. - Writing tests that depend on each other's stored data.
- Skipping labels, so that tests (and screen reader users) cannot find the controls.
Exercise
Choose two of the extension ideas above, implement them, and add a test for each. Then write down, in three sentences, what you would still change if this were a real product.
Key points
- The project uses components for the form, list, item and filters, a reducer for all task changes, and an effect to save the tasks to localStorage.
- State lives in the lowest component that needs it: tasks and filter in App, the draft text in the form, the edit mode in each item.
- Tests written with Testing Library check the behaviour a user sees: adding, completing, clearing, filtering, editing and keeping tasks after a reload.
- The finished app passes its tests, lints without warnings and builds into a small dist folder ready to deploy.
Check yourself
Final exam and certificate
Finish all 38 lessons to unlock a 15-question exam on React. Pass it and you get a certificate with a public link you can share.
Sign in to take the exam