Module 9: Projects
Project 1: Focus Board (A Task Manager with Reducer, Context and Tests)
Build a three-column task board with useReducer, context, localStorage persistence, filtering and tests. Requirements, build order, starter code and an acceptance checklist.
This first project pulls together everything from modules 1 to 4: components and props, lists and keys, controlled forms, useReducer, context, useEffect and a bit of testing. There's no server and no libraries beyond React itself, so every line you write is something you learned in this course.
You're building a small kanban-style board called Focus Board. Tasks live in three columns: To do, Doing and Done. You can add, move, filter and delete them, and they survive a page reload.
What you're building#
┌──────────────────────────────────────────────────────────────┐
│ Focus Board 3 to do · 1 doing · 2 done │
│ [ search tasks... ] Priority: [ all ▾ ] [Clear done] │
├───────────────┬───────────────────┬──────────────────────────┤
│ TO DO │ DOING │ DONE │
│ ┌───────────┐ │ ┌───────────────┐ │ ┌──────────────────────┐ │
│ │ Write docs│ │ │ Fix login bug │ │ │ Set up repo │ │
│ │ high ▶ ✕ │ │ │ medium ◀ ▶ ✕ │ │ │ low ◀ ✕ │ │
│ └───────────┘ │ └───────────────┘ │ └──────────────────────┘ │
│ [ new task... ] [priority ▾] [Add] │
└───────────────┴───────────────────┴──────────────────────────┘
Requirements#
Functional
- Add a task with a title and a priority (low, medium, high). The title is required, trimmed, and at most 80 characters.
- Every task has a status:
todo,doingordone. Buttons move a task one column left or right. - Delete a single task. "Clear done" removes every task in the Done column.
- Filter by text (case-insensitive) and by priority. Filters apply to all three columns at once.
- The header shows how many tasks are in each column. Those counts must not be stored in state.
- Tasks persist in
localStorage. A reload restores them. Corrupted storage data must not crash the app. - Each column shows a friendly empty state when it has no (visible) tasks.
Technical
- State lives in a reducer, provided through context. Components do not call
setStatefor tasks. - The reducer is a pure function in its own file, with no React imports.
- Generate task ids in the event handler, not in the reducer.
- Stable keys on all lists.
- Every interactive element is reachable and operable by keyboard, with a meaningful accessible name (a button that only says "✕" needs an
aria-label).
Build order#
Build in this order. It's deliberate: the pure logic comes first because it's the easiest to get right and test, and the UI is then just wiring.
1. Reducer + tests (no React)
2. Context provider (reducer + persistence)
3. Static UI (hard-coded props, no interaction)
4. Wire up add / move / delete
5. Filters + derived counts
6. Polish: empty states, accessibility, styling
7. Component tests
Step 1: Scaffold#
npm create vite@latest focus-board -- --template react
cd focus-board
npm install
npm install -D vitest jsdom @testing-library/react @testing-library/dom @testing-library/jest-dom @testing-library/user-event
Set up Vitest as shown in the testing lesson. Then organize the code:
src/
├── state/
│ ├── tasksReducer.js
│ ├── tasksReducer.test.js
│ └── TasksContext.jsx
├── components/
│ ├── Header.jsx
│ ├── Filters.jsx
│ ├── TaskForm.jsx
│ ├── Board.jsx
│ ├── Column.jsx
│ └── TaskCard.jsx
├── App.jsx
└── main.jsx
Step 2: The reducer#
A task looks like { id, title, priority, status, createdAt }.
// src/state/tasksReducer.js
export const STATUSES = ["todo", "doing", "done"];
export const PRIORITIES = ["low", "medium", "high"];
export function tasksReducer(state, action) {
switch (action.type) {
case "added":
return [...state, action.task];
case "moved":
return state.map((t) =>
t.id === action.id ? { ...t, status: action.status } : t
);
case "removed":
return state.filter((t) => t.id !== action.id);
case "clearedDone":
return state.filter((t) => t.status !== "done");
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
Write the tests before moving on:
// src/state/tasksReducer.test.js
import { tasksReducer } from "./tasksReducer.js";
const make = (over = {}) => ({
id: "a",
title: "Write docs",
priority: "low",
status: "todo",
createdAt: 1,
...over,
});
describe("tasksReducer", () => {
it("adds a task", () => {
const next = tasksReducer([], { type: "added", task: make() });
expect(next).toHaveLength(1);
});
it("moves only the targeted task", () => {
const state = [make({ id: "a" }), make({ id: "b" })];
const next = tasksReducer(state, { type: "moved", id: "b", status: "doing" });
expect(next.find((t) => t.id === "a").status).toBe("todo");
expect(next.find((t) => t.id === "b").status).toBe("doing");
});
it("does not mutate the previous state", () => {
const state = Object.freeze([Object.freeze(make())]);
expect(() =>
tasksReducer(state, { type: "moved", id: "a", status: "done" })
).not.toThrow();
});
it("clears only done tasks", () => {
const state = [make({ id: "a", status: "done" }), make({ id: "b" })];
expect(tasksReducer(state, { type: "clearedDone" })).toEqual([make({ id: "b" })]);
});
it("throws on unknown actions", () => {
expect(() => tasksReducer([], { type: "nope" })).toThrow();
});
});
Freezing the state objects is a neat trick: in strict mode, trying to mutate a frozen object throws, so the "does not mutate" test fails loudly if you ever write state.push(...).
Step 3: Context with persistence#
// src/state/TasksContext.jsx
import { createContext, useContext, useEffect, useReducer } from "react";
import { tasksReducer } from "./tasksReducer.js";
const STORAGE_KEY = "focus-board:tasks";
const TasksContext = createContext(null);
const TasksDispatchContext = createContext(null);
function loadTasks() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
const parsed = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
export function TasksProvider({ children }) {
const [tasks, dispatch] = useReducer(tasksReducer, undefined, loadTasks);
useEffect(() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
} catch {
/* storage unavailable, the app still works in memory */
}
}, [tasks]);
return (
<TasksContext value={tasks}>
<TasksDispatchContext value={dispatch}>{children}</TasksDispatchContext>
</TasksContext>
);
}
export function useTasks() {
const value = useContext(TasksContext);
if (value === null) throw new Error("useTasks must be used inside TasksProvider");
return value;
}
export function useTasksDispatch() {
const value = useContext(TasksDispatchContext);
if (value === null) throw new Error("useTasksDispatch must be used inside TasksProvider");
return value;
}
useReducer's third argument is an initializer. Passing undefined as the initial argument and loadTasks as the initializer means localStorage is read exactly once. Why two contexts? Components that only dispatch (the form, the card buttons) won't re-render when the task list changes.
Step 4: The form#
// src/components/TaskForm.jsx
import { useState } from "react";
import { useTasksDispatch } from "../state/TasksContext.jsx";
import { PRIORITIES } from "../state/tasksReducer.js";
export default function TaskForm() {
const dispatch = useTasksDispatch();
const [title, setTitle] = useState("");
const [priority, setPriority] = useState("medium");
const trimmed = title.trim();
const tooLong = trimmed.length > 80;
function handleSubmit(e) {
e.preventDefault();
if (!trimmed || tooLong) return;
dispatch({
type: "added",
task: {
id: crypto.randomUUID(), // generated here so the reducer stays pure
title: trimmed,
priority,
status: "todo",
createdAt: Date.now(),
},
});
setTitle("");
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="new-title">New task</label>
<input id="new-title" value={title} onChange={(e) => setTitle(e.target.value)} />
<label htmlFor="new-priority">Priority</label>
<select id="new-priority" value={priority} onChange={(e) => setPriority(e.target.value)}>
{PRIORITIES.map((p) => <option key={p} value={p}>{p}</option>)}
</select>
{tooLong && <p role="alert">Keep it under 80 characters.</p>}
<button disabled={!trimmed || tooLong}>Add</button>
</form>
);
}
Step 5: Board, filters and derived data#
The filters are shared by Filters and Board, so their state belongs in the closest common parent, App.
// src/App.jsx
import { useState } from "react";
import { TasksProvider } from "./state/TasksContext.jsx";
import Header from "./components/Header.jsx";
import Filters from "./components/Filters.jsx";
import Board from "./components/Board.jsx";
import TaskForm from "./components/TaskForm.jsx";
export default function App() {
const [filters, setFilters] = useState({ query: "", priority: "all" });
return (
<TasksProvider>
<Header />
<Filters filters={filters} onChange={setFilters} />
<Board filters={filters} />
<TaskForm />
</TasksProvider>
);
}
// src/components/Board.jsx
import { useTasks } from "../state/TasksContext.jsx";
import { STATUSES } from "../state/tasksReducer.js";
import Column from "./Column.jsx";
export default function Board({ filters }) {
const tasks = useTasks();
const visible = tasks.filter((t) => {
const matchesText = t.title.toLowerCase().includes(filters.query.toLowerCase());
const matchesPriority = filters.priority === "all" || t.priority === filters.priority;
return matchesText && matchesPriority;
});
return (
<div className="board">
{STATUSES.map((status) => (
<Column key={status} status={status} tasks={visible.filter((t) => t.status === status)} />
))}
</div>
);
}
visible isn't state, it's recomputed from tasks and filters. Do the same for the header counts: count tasks by status in Header with a small loop or reduce.
Now you build Column, TaskCard, Filters and Header. A hint for TaskCard: compute the previous and next status from the index in STATUSES, and disable (or hide) the arrow when there isn't one.
const index = STATUSES.indexOf(task.status);
const prev = STATUSES[index - 1]; // undefined for the first column
const next = STATUSES[index + 1]; // undefined for the last
Step 6: Component tests#
Write at least these three, using render with the provider wrapped around the component under test:
- Adding a task with a valid title makes it appear in the To do column and clears the input.
- Clicking the move-right button on a task moves it to the Doing column.
- Typing in the search box hides tasks that don't match.
Clear localStorage in a beforeEach, otherwise tasks from one test leak into the next.
Acceptance checklist#
- I can add a task, and the input clears.
- Empty or whitespace-only titles can't be submitted.
- Tasks move left and right, and the edge columns don't offer an impossible move.
- Filters affect all three columns and the header counts still show the full totals (decide which you want and make it consistent).
- Reloading the page keeps my tasks. Putting
not jsoninto the storage key by hand doesn't break the app. - No component uses
useStateto store the task list. - No list uses an index as a key.
- I can complete the whole flow using only the keyboard.
-
npm run lint, the tests andnpm run buildall pass.
Stretch goals#
- Edit a task's title inline (add an
editedaction and test it). - Drag and drop between columns using the HTML5 drag events.
- Add an
undousing a reducer that wraps your reducer and keepspastandpresentstates. - Sort each column by priority, then by creation date.
- Replace the plain CSS with CSS Modules or Tailwind.
- Deploy it to Vercel or Netlify.
Where people get stuck#
- Putting the filtered list in state and then forgetting to update it when a task changes.
- Calling
crypto.randomUUID()inside the reducer, which makes StrictMode's double invocation create two different ids. - Forgetting that
JSON.parsecan throw on corrupted data. - Passing
dispatchandtasksthrough five layers of props instead of using the context hooks you wrote. - Letting the form call
setTasks-style logic directly instead of dispatching an action.
Practice#
Do these after the acceptance checklist passes. Each one takes under ten minutes and tells you whether you understand what you built.
- Add 5 tasks, reload, then open DevTools and replace the saved value in
localStoragewithnot json{. Reload again. The app should start with an empty board, not a white screen. If it crashes, fixloadTasks. - Add a
renamedaction totasksReducerand write a reducer-only test for it. No rendering needed. Check that the originaltasksarray is not mutated (compare withObject.is). - Open React DevTools, turn on "Highlight updates when components render", and type in the search box. Note which components flash. Then wrap the task card in
memoand compare. Write down in one sentence whether it helped and why.