Skip to content

React Tutorial Practical Projects

Final React Project: A Task Board with Tests and a Production Build

Lesson 148 of 156 4 min read By Deepak Paswan Updated Oct 10, 2026

Full-project lesson · needs Node.js, npm and real project files, so it does not run in the in-page playground.

The final project brings together what you have learned in one real Vite project with several files: a task board (to do, in progress, done). It uses components and props, forms, state with a reducer and Context, a custom hook for storage, accessibility, tests and a production build.

Every file below was created in a real Vite project (React 19.3, Vite 8.3, Vitest 5), the unit and component tests were run (see the output at the end), and the production build was opened in a real browser and clicked through.

Requirements

  • Three columns: To do, In progress and Done.
  • Add a task (Enter works); new tasks start in To do.
  • Move a task to another column with buttons (a keyboard-friendly alternative to drag and drop).
  • Delete a task, and clear all done tasks.
  • A summary: total tasks and how many are done.
  • Tasks are saved in the browser and survive a reload.
  • Accessible: real buttons with descriptive names, a labelled form and a status message.
  • Tested: the reducer (logic) and the board (behaviour).

Set up

terminal
BASH
npm create vite@latest task-board -- --template react
cd task-board
npm install
npm install -D vitest jsdom @testing-library/react @testing-library/dom @testing-library/jest-dom @testing-library/user-event

Replace the generated vite.config.js, and add the test setup, as in Testing React Components:

vite.config.js
JS
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vitest/config'

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    setupFiles: './src/test/setup.js',
  },
})
src/test/setup.js
JS
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach, beforeEach } from 'vitest'

beforeEach(() => {
  localStorage.clear() // every test starts with an empty board
})

afterEach(() => {
  cleanup()
})

Add "test": "vitest" to the scripts in package.json.

The folder structure

code
PLAIN
src/
├── main.jsx
├── App.jsx
├── App.css
├── App.test.jsx
├── state/
│   ├── tasksReducer.js        pure logic: the rules of the board
│   ├── tasksReducer.test.js
│   └── TasksContext.jsx       provider and the useTasks hook
├── hooks/
│   └── useLocalStorage.js     persistence
├── components/
│   ├── AddTaskForm.jsx
│   ├── Column.jsx
│   └── TaskCard.jsx
└── test/
    └── setup.js

The structure follows Organizing Components: pure logic in state/, reusable behaviour in hooks/, UI in components/.

Step 1: the rules (reducer)

Start with the logic, without any UI. It is a pure function, so it is easy to read and test (Reducers and useReducer).

src/state/tasksReducer.js
JS
export const COLUMNS = [
  { id: 'todo', title: 'To do' },
  { id: 'doing', title: 'In progress' },
  { id: 'done', title: 'Done' },
]

export const initialState = { tasks: [] }

export function tasksReducer(state, action) {
  switch (action.type) {
    case 'added':
      return {
        ...state,
        tasks: [...state.tasks, { id: action.id, title: action.title, column: 'todo' }],
      }
    case 'moved':
      return {
        ...state,
        tasks: state.tasks.map((t) => (t.id === action.id ? { ...t, column: action.column } : t)),
      }
    case 'deleted':
      return { ...state, tasks: state.tasks.filter((t) => t.id !== action.id) }
    case 'cleared':
      return { ...state, tasks: state.tasks.filter((t) => t.column !== 'done') }
    default:
      throw new Error('Unknown action: ' + action.type)
  }
}

Four actions, each described by what happened. All updates return new objects and arrays.

Step 2: persistence (custom hook)

src/hooks/useLocalStorage.js
JS
import { useEffect, useState } from 'react'

export function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    try {
      const saved = localStorage.getItem(key)
      return saved !== null ? JSON.parse(saved) : initialValue
    } catch {
      return initialValue
    }
  })

  useEffect(() => {
    try {
      localStorage.setItem(key, JSON.stringify(value))
    } catch {
      // storage can be full or blocked; the board still works without it
    }
  }, [key, value])

  return [value, setValue]
}

This is the hook from Custom Hooks: lazy initial state, an effect that saves on change, and try/catch for storage errors.

Step 3: state shared with Context

The provider connects the reducer to storage. It loads the saved state once, runs the reducer, and saves after every change. Components read it through useTasks().

src/state/TasksContext.jsx
JSX
import { createContext, useContext, useEffect, useMemo, useReducer } from 'react'
import { useLocalStorage } from '../hooks/useLocalStorage.js'
import { initialState, tasksReducer } from './tasksReducer.js'

const TasksContext = createContext(null)

export function TasksProvider({ children }) {
  const [saved, setSaved] = useLocalStorage('task-board', initialState)
  const [state, dispatch] = useReducer(tasksReducer, saved)

  // Save every change
  useEffect(() => {
    setSaved(state)
  }, [state, setSaved])

  const value = useMemo(() => ({ tasks: state.tasks, dispatch }), [state.tasks])
  return <TasksContext value={value}>{children}</TasksContext>
}

export function useTasks() {
  const value = useContext(TasksContext)
  if (value === null) throw new Error('useTasks must be used inside <TasksProvider>')
  return value
}

Step 4: the components

The form is a small controlled component. It creates the id in the event handler, not during rendering.

src/components/AddTaskForm.jsx
JSX
import { useState } from 'react'
import { useTasks } from '../state/TasksContext.jsx'

function AddTaskForm() {
  const { dispatch } = useTasks()
  const [title, setTitle] = useState('')

  function handleSubmit(event) {
    event.preventDefault()
    const clean = title.trim()
    if (!clean) return
    dispatch({ type: 'added', id: crypto.randomUUID(), title: clean })
    setTitle('')
  }

  return (
    <form className="add-form" onSubmit={handleSubmit}>
      <label htmlFor="new-task">New task</label>
      <input
        id="new-task"
        aria-label="New task"
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        placeholder="What needs doing?"
      />
      <button type="submit">Add task</button>
    </form>
  )
}

export default AddTaskForm

A card shows one task and offers move buttons to every other column. The button names include the task title, so a screen reader user hears exactly what each does.

src/components/TaskCard.jsx
JSX
import { COLUMNS } from '../state/tasksReducer.js'
import { useTasks } from '../state/TasksContext.jsx'

function TaskCard({ task }) {
  const { dispatch } = useTasks()
  const otherColumns = COLUMNS.filter((c) => c.id !== task.column)

  return (
    <li className="card">
      <span className="card__title">{task.title}</span>
      <div className="card__actions">
        {otherColumns.map((c) => (
          <button
            key={c.id}
            aria-label={`Move ${task.title} to ${c.title}`}
            onClick={() => dispatch({ type: 'moved', id: task.id, column: c.id })}
          >
            {c.title}
          </button>
        ))}
        <button
          className="danger"
          aria-label={`Delete ${task.title}`}
          onClick={() => dispatch({ type: 'deleted', id: task.id })}
        >
          Delete
        </button>
      </div>
    </li>
  )
}

export default TaskCard

A column filters the tasks for its own id and renders cards, with an empty state:

src/components/Column.jsx
JSX
import { useTasks } from '../state/TasksContext.jsx'
import TaskCard from './TaskCard.jsx'

function Column({ id, title }) {
  const { tasks } = useTasks()
  const columnTasks = tasks.filter((t) => t.column === id)

  return (
    <section className="column" aria-labelledby={`col-${id}`}>
      <h2 id={`col-${id}`}>
        {title} <span className="count">({columnTasks.length})</span>
      </h2>
      {columnTasks.length === 0 ? (
        <p className="empty">Nothing here yet</p>
      ) : (
        <ul>
          {columnTasks.map((task) => (
            <TaskCard key={task.id} task={task} />
          ))}
        </ul>
      )}
    </section>
  )
}

export default Column

Step 5: the app, styles and entry point

src/App.jsx
JSX
import './App.css'
import AddTaskForm from './components/AddTaskForm.jsx'
import Column from './components/Column.jsx'
import { COLUMNS } from './state/tasksReducer.js'
import { TasksProvider, useTasks } from './state/TasksContext.jsx'

function Board() {
  const { tasks, dispatch } = useTasks()
  const done = tasks.filter((t) => t.column === 'done').length

  return (
    <main className="board">
      <header>
        <h1>Task board</h1>
        <p role="status">
          {tasks.length} tasks, {done} done
        </p>
        <button onClick={() => dispatch({ type: 'cleared' })} disabled={done === 0}>
          Clear done
        </button>
      </header>

      <AddTaskForm />

      <div className="columns">
        {COLUMNS.map((c) => (
          <Column key={c.id} id={c.id} title={c.title} />
        ))}
      </div>
    </main>
  )
}

function App() {
  return (
    <TasksProvider>
      <Board />
    </TasksProvider>
  )
}

export default App
src/App.css
CSS
.board { max-width: 960px; margin: 0 auto; padding: 16px; font-family: system-ui, sans-serif; color: #1c1b1f; text-align: left; }
.board header { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; }
.board h1 { margin: 0; flex: 1; }
.add-form { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 16px 0; }
.add-form input { flex: 1; min-width: 200px; padding: 6px 8px; }
.columns { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); align-items: start; }
.column { background: #f4f6f8; border: 1px solid #d0d7de; border-radius: 8px; padding: 12px; }
.column h2 { margin: 0 0 8px; font-size: 18px; }
.column ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
.card { background: #fff; border: 1px solid #d0d7de; border-radius: 6px; padding: 8px; }
.card__title { display: block; margin-bottom: 6px; font-weight: 600; }
.card__actions { display: flex; flex-wrap: wrap; gap: 6px; }
.empty { color: #555; margin: 0; }
button { font: inherit; padding: 4px 10px; border: 1px solid #1a5276; border-radius: 6px; background: #fff; color: #1a5276; cursor: pointer; }
button:hover:not(:disabled) { background: #1a5276; color: #fff; }
button:disabled { opacity: 0.4; cursor: not-allowed; }
button.danger { border-color: #b71c1c; color: #b71c1c; }
button:focus-visible, input:focus-visible { outline: 3px solid #ca4b00; outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { * { transition: none !important; } }

src/main.jsx stays as the template created it (it renders <App /> inside StrictMode).

Step 6: tests

First the rules. Reducer tests are fast and need no React:

src/state/tasksReducer.test.js
JS
import { describe, expect, it } from 'vitest'
import { initialState, tasksReducer } from './tasksReducer.js'

describe('tasksReducer', () => {
  it('adds a task to the To do column', () => {
    const next = tasksReducer(initialState, { type: 'added', id: 'a', title: 'Write docs' })
    expect(next.tasks).toEqual([{ id: 'a', title: 'Write docs', column: 'todo' }])
  })

  it('does not change the old state (no mutation)', () => {
    const before = { tasks: [{ id: 'a', title: 'Write docs', column: 'todo' }] }
    const copy = structuredClone(before)
    tasksReducer(before, { type: 'moved', id: 'a', column: 'done' })
    expect(before).toEqual(copy)
  })

  it('moves a task to another column', () => {
    const start = { tasks: [{ id: 'a', title: 'Write docs', column: 'todo' }] }
    const next = tasksReducer(start, { type: 'moved', id: 'a', column: 'doing' })
    expect(next.tasks[0].column).toBe('doing')
  })

  it('deletes a task', () => {
    const start = { tasks: [{ id: 'a', title: 'A', column: 'todo' }, { id: 'b', title: 'B', column: 'todo' }] }
    expect(tasksReducer(start, { type: 'deleted', id: 'a' }).tasks).toHaveLength(1)
  })

  it('clears only done tasks', () => {
    const start = { tasks: [{ id: 'a', title: 'A', column: 'done' }, { id: 'b', title: 'B', column: 'doing' }] }
    const next = tasksReducer(start, { type: 'cleared' })
    expect(next.tasks.map((t) => t.id)).toEqual(['b'])
  })

  it('throws on an unknown action', () => {
    expect(() => tasksReducer(initialState, { type: 'nope' })).toThrow('Unknown action')
  })
})

Then the behaviour of the whole board, as a user would use it:

src/App.test.jsx
JSX
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import App from './App.jsx'

async function addTask(user, title) {
  await user.type(screen.getByLabelText('New task', { selector: 'input' }), title)
  await user.click(screen.getByRole('button', { name: 'Add task' }))
}

describe('Task board', () => {
  it('adds a task to the To do column', async () => {
    const user = userEvent.setup()
    render(<App />)

    await addTask(user, 'Write docs')

    const todo = screen.getByRole('region', { name: /to do/i })
    expect(within(todo).getByText('Write docs')).toBeInTheDocument()
    expect(screen.getByText('1 tasks, 0 done')).toBeInTheDocument()
  })

  it('moves a task to Done and clears it', async () => {
    const user = userEvent.setup()
    render(<App />)

    await addTask(user, 'Write docs')
    await user.click(screen.getByRole('button', { name: 'Move Write docs to Done' }))

    const done = screen.getByRole('region', { name: /done/i })
    expect(within(done).getByText('Write docs')).toBeInTheDocument()
    expect(screen.getByText('1 tasks, 1 done')).toBeInTheDocument()

    await user.click(screen.getByRole('button', { name: 'Clear done' }))
    expect(screen.queryByText('Write docs')).not.toBeInTheDocument()
  })

  it('ignores an empty title', async () => {
    const user = userEvent.setup()
    render(<App />)

    await user.click(screen.getByRole('button', { name: 'Add task' }))

    expect(screen.getByText('0 tasks, 0 done')).toBeInTheDocument()
  })
})

Run the tests with npm test -- --run. The result in the real project:

code
OUTPUT
 Test Files  2 passed (2)
      Tests  9 passed (9)

Step 7: build and check

terminal
BASH
npm run build
npm run preview

This project was built with vite build and the preview was opened in a real browser. The checks that passed: adding two tasks with Enter, moving one through In progress to Done, clearing done tasks, deleting the last task, and the counts and headings updating each time. Because of the useLocalStorage hook, a reload keeps the board.

What you used from the course

IdeaWhere it appears
Components, props, compositionColumn, TaskCard, AddTaskForm
Controlled form, onSubmitAddTaskForm
Lists with stable keysColumn, TaskCard
Derived stateColumn contents, done count, “Clear done” disabled state
Reducer for related actionstasksReducer
Context with a guard hookTasksContext
Custom hook and effectsuseLocalStorage
Immutable updatesEvery reducer case
AccessibilityLabels, role="status", descriptive button names, focus styles
TestingReducer and behaviour tests
Production buildnpm run build and npm run preview

Where to go next

  1. Drag and drop, while keeping the buttons for keyboard users (a library such as dnd-kit is a common choice).
  2. A backend: load and save tasks through an API, with loading, error and retry states and optimistic updates.
  3. Routing: one board per project at /projects/:id, with React Router.
  4. TypeScript: convert the project and type the actions as a discriminated union (React with TypeScript).
  5. Deploy it (Deployment Fundamentals).

Key points

  • A real project combines small, tested pieces: pure logic, a reducer, a provider, hooks and focused components.
  • Test the rules directly and the behaviour through the UI, then build and preview the production version.
  • Keep accessibility, empty states and persistence in the definition of “done”.