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
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:
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vitest/config'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: './src/test/setup.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
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).
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)
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().
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.
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.
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:
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
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
.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:
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:
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:
Test Files 2 passed (2)
Tests 9 passed (9)
Step 7: build and check
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
| Idea | Where it appears |
|---|---|
| Components, props, composition | Column, TaskCard, AddTaskForm |
Controlled form, onSubmit | AddTaskForm |
| Lists with stable keys | Column, TaskCard |
| Derived state | Column contents, done count, “Clear done” disabled state |
| Reducer for related actions | tasksReducer |
| Context with a guard hook | TasksContext |
| Custom hook and effects | useLocalStorage |
| Immutable updates | Every reducer case |
| Accessibility | Labels, role="status", descriptive button names, focus styles |
| Testing | Reducer and behaviour tests |
| Production build | npm run build and npm run preview |
Where to go next
- Drag and drop, while keeping the buttons for keyboard users (a library such as dnd-kit is a common choice).
- A backend: load and save tasks through an API, with loading, error and retry states and optimistic updates.
- Routing: one board per project at
/projects/:id, with React Router. - TypeScript: convert the project and type the actions as a discriminated union (React with TypeScript).
- 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”.