Himanshu SharmaSoftware engineer
Let’s talk

React · Lesson 38 of 38

Project: a task manager

Build a complete task manager in React 19 step by step: components, a reducer, filters, editing, localStorage, tests with Vitest and a production build.

45 minQuality and shippingUpdated 24 October 2026

By the end of this lessonCombine components, props, state, reducers, effects, forms, accessibility, testing and a build into one working React application.

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:

text
App                       tasks (reducer), filter
├── TaskForm              draft text
├── Filters
├── status line
├── TaskList
│   └── TaskItem          editing, draft text of the edit box
└── Clear completed button

Rules 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

bash
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 dev

Replace 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:

javascript
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:

jsx
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":

jsx
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:

jsx
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:

jsx
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:

jsx
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:

jsx
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;
result
Task manager
New task  Add
allactivedone
1 of 2 tasks left
 Buy milk Delete
Clear completed

In 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:

jsx
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:

text
 ✓ 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

bash
npm run lint
npm run build

Linting printed nothing (no problems), and the build produced:

text
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 69ms

Upload 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:

  1. Due dates and sorting. Add a date to each task and sort by it (derived data, immutable sort).
  2. Priorities with a select. Another controlled input and a new field in the reducer.
  3. Move the state into context with TasksProvider, useTasks and useTasksDispatch, so that the list and form need no props.
  4. Replace the manual persistence with the useLocalStorage hook from the custom hooks lesson (try it with useReducer too).
  5. Optimistic server sync. Replace localStorage with a fake API and use useOptimistic.
  6. Routing. Add /active and /done routes instead of a filter state, so that filters are in the URL.
  7. Keyboard shortcuts and focus. Return focus to the new-task input after adding.
  8. Animations and a dark theme using CSS variables.
  9. Deploy it and send the link to a friend.

Common mistakes in projects like this

  • Keeping both tasks and visibleTasks in state.
  • Generating ids in the reducer or during render, which breaks purity.
  • Using the array index as the key for tasks that can be removed or reordered.
  • Forgetting event.preventDefault() in the form.
  • Reading localStorage directly 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

1. Where does the project keep the list of tasks?
2. Why is the filter stored as state but the filtered list is not?
3. What makes the saved tasks survive a page reload?
4. Why does the test find the checkbox with getByRole('checkbox', { name: 'Buy milk' })?

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