# Capstone - Task Management

Course: [React](https://www.devtutorials.ai/learn/react) · Section: Project-Based Learning
Source: https://www.devtutorials.ai/learn/react/project/capstone-task-management

> Build a complete Task Manager app step by step: setup, reusable components, routing, shared state with context and a reducer, validated forms, an API service, error handling and performance.

## What is a Capstone Project?

A **capstone project** is one bigger project that joins together the skills from a whole course. Instead of small examples, you build one complete app.

In this lesson you build a **Task Manager** for the Book Corner team. The team can see tasks, add new ones, mark them done, delete them, filter them and see simple stats. The tasks are saved through an API.

### Real-World Example

A small team needs one app to track its work. Each person opens `/tasks`, adds a task with a title and a priority, and marks it done when finished.

The data is saved through a REST API, so everyone sees the same list after a refresh. This capstone builds that app and uses every skill from the course:

- **TypeScript types** for a task (Lesson 2)
- **Reusable components** with props (Lessons 3–4)
- **Routing** with three pages (Lesson 16)
- **Context and a reducer** for shared state (Lessons 14–15)
- **Forms and validation** (Lessons 9–10)
- **The Fetch API** with a service layer (Lesson 22)
- **Error boundaries, memo and lazy loading** (Lessons 24–25)

<figure className="react-fig">
<svg viewBox="0 0 520 120" role="img" aria-labelledby="fig-c-plan" style={{ width: "100%", maxWidth: 520, height: "auto" }}>
  <title id="fig-c-plan">The capstone is built in six steps, each one using skills from earlier chapters</title>
  <rect x="5" y="35" width="80" height="50" rx="8" style={{ fill: "var(--panel2)", stroke: "var(--faint)", strokeWidth: 1.5 }} />
  <text x="45.0" y="57.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 13, fontWeight: 700, fontFamily: "var(--sans)" }}>1 Setup</text>
  <text x="45.0" y="74.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>types</text>
  <rect x="91" y="35" width="80" height="50" rx="8" style={{ fill: "var(--accent-soft)", stroke: "var(--accent)", strokeWidth: 1.5 }} />
  <text x="131.0" y="57.0" textAnchor="middle" style={{ fill: "var(--accent)", fontSize: 13, fontWeight: 700, fontFamily: "var(--sans)" }}>2 Parts</text>
  <text x="131.0" y="74.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>components</text>
  <rect x="177" y="35" width="80" height="50" rx="8" style={{ fill: "var(--accent-secondary-soft)", stroke: "var(--accent-secondary)", strokeWidth: 1.5 }} />
  <text x="217.0" y="57.0" textAnchor="middle" style={{ fill: "var(--accent-secondary)", fontSize: 13, fontWeight: 700, fontFamily: "var(--sans)" }}>3 Pages</text>
  <text x="217.0" y="74.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>routing</text>
  <rect x="263" y="35" width="80" height="50" rx="8" style={{ fill: "var(--info-soft)", stroke: "var(--info)", strokeWidth: 1.5 }} />
  <text x="303.0" y="57.0" textAnchor="middle" style={{ fill: "var(--info)", fontSize: 13, fontWeight: 700, fontFamily: "var(--sans)" }}>4 State</text>
  <text x="303.0" y="74.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>context</text>
  <rect x="349" y="35" width="80" height="50" rx="8" style={{ fill: "var(--ok-soft)", stroke: "var(--ok)", strokeWidth: 1.5 }} />
  <text x="389.0" y="57.0" textAnchor="middle" style={{ fill: "var(--ok)", fontSize: 13, fontWeight: 700, fontFamily: "var(--sans)" }}>5 Forms</text>
  <text x="389.0" y="74.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>validation</text>
  <rect x="435" y="35" width="80" height="50" rx="8" style={{ fill: "var(--bad-soft)", stroke: "var(--bad)", strokeWidth: 1.5 }} />
  <text x="475.0" y="57.0" textAnchor="middle" style={{ fill: "var(--bad)", fontSize: 13, fontWeight: 700, fontFamily: "var(--sans)" }}>6 API</text>
  <text x="475.0" y="74.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>fetch</text>
  <rect x="5" y="35" width="80" height="50" rx="8" style={{ fill: "none", stroke: "var(--accent-secondary)", strokeWidth: 3 }} opacity="0">
    <animate attributeName="opacity" values="0;1;0;0" keyTimes="0;0.15;0.4;1" dur="4.2s" begin="0.0s" repeatCount="indefinite" />
  </rect>
  <rect x="91" y="35" width="80" height="50" rx="8" style={{ fill: "none", stroke: "var(--accent-secondary)", strokeWidth: 3 }} opacity="0">
    <animate attributeName="opacity" values="0;1;0;0" keyTimes="0;0.15;0.4;1" dur="4.2s" begin="0.6s" repeatCount="indefinite" />
  </rect>
  <rect x="177" y="35" width="80" height="50" rx="8" style={{ fill: "none", stroke: "var(--accent-secondary)", strokeWidth: 3 }} opacity="0">
    <animate attributeName="opacity" values="0;1;0;0" keyTimes="0;0.15;0.4;1" dur="4.2s" begin="1.2s" repeatCount="indefinite" />
  </rect>
  <rect x="263" y="35" width="80" height="50" rx="8" style={{ fill: "none", stroke: "var(--accent-secondary)", strokeWidth: 3 }} opacity="0">
    <animate attributeName="opacity" values="0;1;0;0" keyTimes="0;0.15;0.4;1" dur="4.2s" begin="1.7999999999999998s" repeatCount="indefinite" />
  </rect>
  <rect x="349" y="35" width="80" height="50" rx="8" style={{ fill: "none", stroke: "var(--accent-secondary)", strokeWidth: 3 }} opacity="0">
    <animate attributeName="opacity" values="0;1;0;0" keyTimes="0;0.15;0.4;1" dur="4.2s" begin="2.4s" repeatCount="indefinite" />
  </rect>
  <rect x="435" y="35" width="80" height="50" rx="8" style={{ fill: "none", stroke: "var(--accent-secondary)", strokeWidth: 3 }} opacity="0">
    <animate attributeName="opacity" values="0;1;0;0" keyTimes="0;0.15;0.4;1" dur="4.2s" begin="3.0s" repeatCount="indefinite" />
  </rect>
  <text x="260" y="110" textAnchor="middle" style={{ fill: "var(--muted)", fontSize: 12, fontWeight: 400, fontFamily: "var(--sans)" }}>each step adds one layer, and the app works after every step</text>
</svg>
<figcaption>Build a little, run it, then add the next part.</figcaption>
</figure>

## Project Setup and Application Structure

On your own computer, you create the project with Vite and add React Router:

```bash
npm create vite@latest task-manager -- --template react-ts
cd task-manager
npm install react-router-dom
npm run dev
```

Then you plan the folders. Each folder has one job, so files are easy to find.

<figure className="react-fig">
<svg viewBox="0 0 520 230" role="img" aria-labelledby="fig-c-tree" style={{ width: "100%", maxWidth: 520, height: "auto" }}>
  <title id="fig-c-tree">A typical folder structure for the Task Manager</title>
  <rect x="10" y="10" width="250" height="210" rx="10" style={{ fill: "var(--panel2)", stroke: "var(--faint)" }} />
  <text x="25" y="34" textAnchor="start" style={{ fill: "var(--accent)", fontSize: 12, fontWeight: 700, fontFamily: "var(--mono)" }}>src/</text>
  <text x="43" y="54" textAnchor="start" style={{ fill: "var(--ink2)", fontSize: 12, fontWeight: 400, fontFamily: "var(--mono)" }}>types.ts</text>
  <text x="43" y="74" textAnchor="start" style={{ fill: "var(--ink2)", fontSize: 12, fontWeight: 700, fontFamily: "var(--mono)" }}>components/</text>
  <text x="61" y="94" textAnchor="start" style={{ fill: "var(--ink2)", fontSize: 12, fontWeight: 400, fontFamily: "var(--mono)" }}>Button.tsx  TaskCard.tsx</text>
  <text x="43" y="114" textAnchor="start" style={{ fill: "var(--ink2)", fontSize: 12, fontWeight: 700, fontFamily: "var(--mono)" }}>pages/</text>
  <text x="61" y="134" textAnchor="start" style={{ fill: "var(--ink2)", fontSize: 12, fontWeight: 700, fontFamily: "var(--mono)" }}>TaskListPage.tsx ...</text>
  <text x="43" y="154" textAnchor="start" style={{ fill: "var(--ink2)", fontSize: 12, fontWeight: 700, fontFamily: "var(--mono)" }}>context/</text>
  <text x="61" y="174" textAnchor="start" style={{ fill: "var(--ink2)", fontSize: 12, fontWeight: 400, fontFamily: "var(--mono)" }}>TasksContext.tsx</text>
  <text x="43" y="194" textAnchor="start" style={{ fill: "var(--ink2)", fontSize: 12, fontWeight: 700, fontFamily: "var(--mono)" }}>services/</text>
  <text x="61" y="214" textAnchor="start" style={{ fill: "var(--ink2)", fontSize: 12, fontWeight: 400, fontFamily: "var(--mono)" }}>taskService.ts</text>
  <text x="275" y="34" textAnchor="start" style={{ fill: "var(--muted)", fontSize: 12, fontWeight: 400, fontFamily: "var(--sans)" }}>types: the shape of a task</text>
  <text x="275" y="74" textAnchor="start" style={{ fill: "var(--muted)", fontSize: 12, fontWeight: 400, fontFamily: "var(--sans)" }}>components: small reusable parts</text>
  <text x="275" y="114" textAnchor="start" style={{ fill: "var(--muted)", fontSize: 12, fontWeight: 400, fontFamily: "var(--sans)" }}>pages: one per route</text>
  <text x="275" y="154" textAnchor="start" style={{ fill: "var(--muted)", fontSize: 12, fontWeight: 400, fontFamily: "var(--sans)" }}>context: shared state</text>
  <text x="275" y="194" textAnchor="start" style={{ fill: "var(--muted)", fontSize: 12, fontWeight: 400, fontFamily: "var(--sans)" }}>services: talk to the API</text>
</svg>
<figcaption>Each folder has one job. The playground keeps files flat, but the roles are the same.</figcaption>
</figure>

The first file to write is `types.ts`. It says what a task looks like. Every other file uses these types.

- `Priority` can only be `"low"`, `"medium"` or `"high"`.
- `NewTask` is `Omit<Task, "id">`: a task without an `id`. The server gives the id when it saves the task.

      <h1>Task Manager</h1>
      {tasks.map((task) => (
        <p key={task.id}>
          {task.done ? "✓" : "○"} {task.title} ({task.priority})
        </p>
      ))}
    </div>
  );
}
`,
    "types.ts": `export type Priority = "low" | "medium" | "high";

  id: number;
  title: string;
  priority: Priority;
  done: boolean;
}

// A task before the server gives it an id
`,
    "styles.css": `.app { max-width: 480px; font-family: sans-serif; }
h1 { margin: 0 0 4px; color: #2b59c3; }
nav { display: flex; gap: 8px; margin: 8px 0 14px; }
nav a { color: #2b59c3; text-decoration: none; padding: 4px 10px; border-radius: 6px; }
nav a.active { background: #2b59c3; color: white; }
.btn { padding: 4px 10px; border-radius: 6px; border: 1px solid #bbb; background: white; cursor: pointer; }
.btn.primary { background: #2b59c3; border-color: #2b59c3; color: white; }
.btn.danger { border-color: #d33; color: #d33; }
.badge { padding: 1px 8px; border-radius: 10px; font-size: 12px; }
.badge.high { background: #fde2e2; color: #b42318; }
.badge.medium { background: #fff3cd; color: #8a6d00; }
.badge.low { background: #e3f6e8; color: #1e7b34; }
.task { display: flex; align-items: center; gap: 8px; padding: 8px; border: 1px solid #ddd; border-radius: 8px; margin-bottom: 6px; }
.task .title { flex: 1; }
.task.done .title { text-decoration: line-through; color: #888; }
.filters { display: flex; gap: 6px; margin-bottom: 10px; }
form { display: grid; gap: 8px; margin-bottom: 12px; }
input, select { padding: 6px; }
.error { color: #b42318; margin: 0; }
.bar { height: 10px; background: #eee; border-radius: 5px; }
.bar div { height: 10px; background: seagreen; border-radius: 5px; }
`,
    "main.tsx": `import { createRoot } from "react-dom/client";

createRoot(document.getElementById("root")!).render(<App />);
`,
  }}
/>

Try changing `priority: "high"` to `priority: "urgent"` in the lab. The editor marks it red, because `"urgent"` is not a `Priority`. Types catch mistakes before you run the app.

## Building Reusable Components

Next, split the screen into small parts that you can use again:

- `Button` — one button style for the whole app. The `variant` prop picks the colour: `variant = "plain"` is the default value.
- `PriorityBadge` — a coloured label. `className={"badge " + priority}` gives classes like `badge high`.
- `TaskCard` — one task row, with a checkbox, the title, the badge and a Delete button.

`TaskCard` does not change any data itself. It receives the task and two functions, `onToggle` and `onRemove`, and calls them. The parent decides what happens. This keeps the card simple and reusable.

      <h1>Task Manager</h1>
      {tasks.map((task) => (
      ))}
    </div>
  );
}
`,
    "TaskCard.tsx": `import { memo } from "react";

interface TaskCardProps {
  task: Task;
  onToggle: (task: Task) => void;
  onRemove: (id: number) => void;
}

function TaskCard({ task, onToggle, onRemove }: TaskCardProps) {
  return (
    <div className={task.done ? "task done" : "task"}>
      <input type="checkbox" checked={task.done} onChange={() => onToggle(task)} />
      <span className="title">{task.title}</span>
      <Button variant="danger" onClick={() => onRemove(task.id)}>Delete</Button>
    </div>
  );
}

`,
    "Button.tsx": `import type { ReactNode } from "react";

interface ButtonProps {
  children: ReactNode;
  onClick?: () => void;
  variant?: "primary" | "danger" | "plain";
  type?: "button" | "submit";
}

  return (
    <button type={type} className={"btn " + variant} onClick={onClick}>
      {children}
    </button>
  );
}
`,
    "PriorityBadge.tsx": `import type { Priority } from "./types";

  return <span className={"badge " + priority}>{priority}</span>;
}
`,
    "types.ts": `export type Priority = "low" | "medium" | "high";

  id: number;
  title: string;
  priority: Priority;
  done: boolean;
}

// A task before the server gives it an id
`,
    "data.ts": `import type { Task } from "./types";

  { id: 1, title: "Order new books", priority: "high", done: false },
  { id: 2, title: "Update the price list", priority: "medium", done: true },
  { id: 3, title: "Clean the shelves", priority: "low", done: false },
];
`,
    "styles.css": `.app { max-width: 480px; font-family: sans-serif; }
h1 { margin: 0 0 4px; color: #2b59c3; }
nav { display: flex; gap: 8px; margin: 8px 0 14px; }
nav a { color: #2b59c3; text-decoration: none; padding: 4px 10px; border-radius: 6px; }
nav a.active { background: #2b59c3; color: white; }
.btn { padding: 4px 10px; border-radius: 6px; border: 1px solid #bbb; background: white; cursor: pointer; }
.btn.primary { background: #2b59c3; border-color: #2b59c3; color: white; }
.btn.danger { border-color: #d33; color: #d33; }
.badge { padding: 1px 8px; border-radius: 10px; font-size: 12px; }
.badge.high { background: #fde2e2; color: #b42318; }
.badge.medium { background: #fff3cd; color: #8a6d00; }
.badge.low { background: #e3f6e8; color: #1e7b34; }
.task { display: flex; align-items: center; gap: 8px; padding: 8px; border: 1px solid #ddd; border-radius: 8px; margin-bottom: 6px; }
.task .title { flex: 1; }
.task.done .title { text-decoration: line-through; color: #888; }
.filters { display: flex; gap: 6px; margin-bottom: 10px; }
form { display: grid; gap: 8px; margin-bottom: 12px; }
input, select { padding: 6px; }
.error { color: #b42318; margin: 0; }
.bar { height: 10px; background: #eee; border-radius: 5px; }
.bar div { height: 10px; background: seagreen; border-radius: 5px; }
`,
    "main.tsx": `import { createRoot } from "react-dom/client";

createRoot(document.getElementById("root")!).render(<App />);
`,
  }}
/>

## Implementing Routing and Navigation

The app has three pages. React Router shows the right page for each URL.

<figure className="react-fig">
<svg viewBox="0 0 520 80" role="img" aria-labelledby="fig-c-routes" style={{ width: "100%", maxWidth: 520, height: "auto" }}>
  <title id="fig-c-routes">The Task Manager has three pages, each with its own URL</title>
  <rect x="5" y="15" width="160" height="50" rx="8" style={{ fill: "var(--accent-soft)", stroke: "var(--accent)", strokeWidth: 1.5 }} />
  <text x="85.0" y="37.0" textAnchor="middle" style={{ fill: "var(--accent)", fontSize: 13, fontWeight: 700, fontFamily: "var(--mono)" }}>/</text>
  <text x="85.0" y="54.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>TaskListPage</text>
  <rect x="180" y="15" width="160" height="50" rx="8" style={{ fill: "var(--accent-secondary-soft)", stroke: "var(--accent-secondary)", strokeWidth: 1.5 }} />
  <text x="260.0" y="37.0" textAnchor="middle" style={{ fill: "var(--accent-secondary)", fontSize: 13, fontWeight: 700, fontFamily: "var(--mono)" }}>/new</text>
  <text x="260.0" y="54.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>NewTaskPage</text>
  <rect x="355" y="15" width="160" height="50" rx="8" style={{ fill: "var(--ok-soft)", stroke: "var(--ok)", strokeWidth: 1.5 }} />
  <text x="435.0" y="37.0" textAnchor="middle" style={{ fill: "var(--ok)", fontSize: 13, fontWeight: 700, fontFamily: "var(--mono)" }}>/stats</text>
  <text x="435.0" y="54.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>StatsPage (lazy)</text>
  <rect x="5" y="15" width="160" height="50" rx="8" style={{ fill: "none", stroke: "var(--accent-secondary)", strokeWidth: 3 }} opacity="0">
    <animate attributeName="opacity" values="0;1;0;0" keyTimes="0;0.15;0.4;1" dur="4.5s" begin="0s" repeatCount="indefinite" />
  </rect>
  <rect x="180" y="15" width="160" height="50" rx="8" style={{ fill: "none", stroke: "var(--accent-secondary)", strokeWidth: 3 }} opacity="0">
    <animate attributeName="opacity" values="0;1;0;0" keyTimes="0;0.15;0.4;1" dur="4.5s" begin="1.5s" repeatCount="indefinite" />
  </rect>
  <rect x="355" y="15" width="160" height="50" rx="8" style={{ fill: "none", stroke: "var(--accent-secondary)", strokeWidth: 3 }} opacity="0">
    <animate attributeName="opacity" values="0;1;0;0" keyTimes="0;0.15;0.4;1" dur="4.5s" begin="3s" repeatCount="indefinite" />
  </rect>
</svg>
<figcaption>The nav bar links to each URL. A * route catches everything else.</figcaption>
</figure>

In the lab below:

- `<NavLink to="/">` makes a link. NavLink adds the class `active` to the link of the current page, so the CSS can highlight it. `end` means "only for exactly `/`".
- `<Route path="/tasks/:id">` has a **URL parameter**. In `TaskDetailPage`, `const { id } = useParams();` reads it.
- `<Route path="*">` catches any unknown URL and shows "Page not found".

Click a task title, then use the address bar's Back button.

      {sampleTasks.map((t) => (
        <li key={t.id}>
          <Link to={"/tasks/" + t.id}>{t.title}</Link>
        </li>
      ))}
    </ul>
  );
}

function TaskDetailPage() {
  const { id } = useParams();
  const task = sampleTasks.find((t) => t.id === Number(id));
  if (!task) return <p>Task not found.</p>;
  return (
    <div>
      <h2>{task.title}</h2>
      <p>
        Priority: <PriorityBadge priority={task.priority} />
      </p>
      <p>Status: {task.done ? "done" : "to do"}</p>
      <Link to="/">Back to the list</Link>
    </div>
  );
}

  return (
      <div className="app">
        <h1>Task Manager</h1>
        <nav>
          <NavLink to="/" end>Tasks</NavLink>
          <NavLink to="/about">About</NavLink>
        </nav>
          <Route path="/" element={<TaskListPage />} />
          <Route path="/tasks/:id" element={<TaskDetailPage />} />
          <Route path="/about" element={<p>A small task app for the Book Corner team.</p>} />
          <Route path="*" element={<p>Page not found.</p>} />
      </div>
  );
}
`,
    "PriorityBadge.tsx": `import type { Priority } from "./types";

  return <span className={"badge " + priority}>{priority}</span>;
}
`,
    "types.ts": `export type Priority = "low" | "medium" | "high";

  id: number;
  title: string;
  priority: Priority;
  done: boolean;
}

// A task before the server gives it an id
`,
    "data.ts": `import type { Task } from "./types";

  { id: 1, title: "Order new books", priority: "high", done: false },
  { id: 2, title: "Update the price list", priority: "medium", done: true },
  { id: 3, title: "Clean the shelves", priority: "low", done: false },
];
`,
    "styles.css": `.app { max-width: 480px; font-family: sans-serif; }
h1 { margin: 0 0 4px; color: #2b59c3; }
nav { display: flex; gap: 8px; margin: 8px 0 14px; }
nav a { color: #2b59c3; text-decoration: none; padding: 4px 10px; border-radius: 6px; }
nav a.active { background: #2b59c3; color: white; }
.btn { padding: 4px 10px; border-radius: 6px; border: 1px solid #bbb; background: white; cursor: pointer; }
.btn.primary { background: #2b59c3; border-color: #2b59c3; color: white; }
.btn.danger { border-color: #d33; color: #d33; }
.badge { padding: 1px 8px; border-radius: 10px; font-size: 12px; }
.badge.high { background: #fde2e2; color: #b42318; }
.badge.medium { background: #fff3cd; color: #8a6d00; }
.badge.low { background: #e3f6e8; color: #1e7b34; }
.task { display: flex; align-items: center; gap: 8px; padding: 8px; border: 1px solid #ddd; border-radius: 8px; margin-bottom: 6px; }
.task .title { flex: 1; }
.task.done .title { text-decoration: line-through; color: #888; }
.filters { display: flex; gap: 6px; margin-bottom: 10px; }
form { display: grid; gap: 8px; margin-bottom: 12px; }
input, select { padding: 6px; }
.error { color: #b42318; margin: 0; }
.bar { height: 10px; background: #eee; border-radius: 5px; }
.bar div { height: 10px; background: seagreen; border-radius: 5px; }
`,
    "main.tsx": `import { createRoot } from "react-dom/client";

createRoot(document.getElementById("root")!).render(<App />);
`,
  }}
/>

## Managing State Across the Application

Many parts of the app need the task list: the header shows how many tasks are left, the list page shows them, and the stats page counts them. Passing the list through props to all of them would be messy.

So the tasks live in a **context**, managed by a **reducer**. Open the `TasksContext.tsx` tab:

- `tasksReducer` is the only place that changes the list. Each `case` handles one action: `"added"`, `"toggled"` or `"removed"`.
- `TasksProvider` wraps the app and shares the tasks and three functions: `addTask`, `toggleTask`, `removeTask`.
- `useTasks()` is a small custom hook. Any component calls it to get the tasks. It throws a clear error if you forget the provider.

<figure className="react-fig">
<svg viewBox="0 0 520 120" role="img" aria-labelledby="fig-c-arch" style={{ width: "100%", maxWidth: 520, height: "auto" }}>
  <title id="fig-c-arch">Data flows from the API through the service and the context to the pages and components</title>
  <rect x="5" y="35" width="90" height="50" rx="8" style={{ fill: "var(--bad-soft)", stroke: "var(--bad)", strokeWidth: 1.5 }} />
  <text x="50.0" y="57.0" textAnchor="middle" style={{ fill: "var(--bad)", fontSize: 13, fontWeight: 700, fontFamily: "var(--mono)" }}>API</text>
  <text x="50.0" y="74.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>/tasks</text>
  <path d="M95 60 L115 60" style={{ stroke: "var(--faint)", strokeWidth: 2, fill: "none" }} />
  <path d="M115 60 L106.7 63.5 L106.7 56.5 Z" style={{ fill: "var(--faint)" }} />
  <rect x="120" y="35" width="100" height="50" rx="8" style={{ fill: "var(--accent-secondary-soft)", stroke: "var(--accent-secondary)", strokeWidth: 1.5 }} />
  <text x="170.0" y="57.0" textAnchor="middle" style={{ fill: "var(--accent-secondary)", fontSize: 13, fontWeight: 700, fontFamily: "var(--mono)" }}>taskService</text>
  <text x="170.0" y="74.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>fetch</text>
  <path d="M220 60 L240 60" style={{ stroke: "var(--faint)", strokeWidth: 2, fill: "none" }} />
  <path d="M240 60 L231.7 63.5 L231.7 56.5 Z" style={{ fill: "var(--faint)" }} />
  <rect x="245" y="35" width="110" height="50" rx="8" style={{ fill: "var(--info-soft)", stroke: "var(--info)", strokeWidth: 1.5 }} />
  <text x="300.0" y="57.0" textAnchor="middle" style={{ fill: "var(--info)", fontSize: 13, fontWeight: 700, fontFamily: "var(--mono)" }}>TasksContext</text>
  <text x="300.0" y="74.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>state</text>
  <path d="M355 60 L375 60" style={{ stroke: "var(--faint)", strokeWidth: 2, fill: "none" }} />
  <path d="M375 60 L366.7 63.5 L366.7 56.5 Z" style={{ fill: "var(--faint)" }} />
  <rect x="380" y="35" width="135" height="50" rx="8" style={{ fill: "var(--accent-soft)", stroke: "var(--accent)", strokeWidth: 1.5 }} />
  <text x="447.5" y="57.0" textAnchor="middle" style={{ fill: "var(--accent)", fontSize: 13, fontWeight: 700, fontFamily: "var(--sans)" }}>Pages</text>
  <text x="447.5" y="74.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>components</text>
  <circle r="6" style={{ fill: "var(--accent-secondary)" }}>
    <animateMotion dur="2.6s" repeatCount="indefinite" path="M95 60 L115 60 M220 60 L240 60 M355 60 L375 60" />
  </circle>
  <text x="260" y="110" textAnchor="middle" style={{ fill: "var(--muted)", fontSize: 12, fontWeight: 400, fontFamily: "var(--sans)" }}>user actions travel back the other way</text>
</svg>
<figcaption>Pages never call fetch. They ask the context, which asks the service.</figcaption>
</figure>

      {tasks.map((t: Task) => (
      ))}
      <Button variant="primary" onClick={() => addTask({ title: "Plan the book fair", priority: "medium", done: false })}>
        Add a sample task
    </div>
  );
}

  return (
      <div className="app">
      </div>
  );
}
`,
    "TasksContext.tsx": `import { createContext, useContext, useReducer, type ReactNode } from "react";

type Action =
  | { type: "added"; task: NewTask }
  | { type: "toggled"; id: number }
  | { type: "removed"; id: number };

function tasksReducer(tasks: Task[], action: Action): Task[] {
  switch (action.type) {
    case "added":
      return [...tasks, { ...action.task, id: Date.now() }];
    case "toggled":
      return tasks.map((t) => (t.id === action.id ? { ...t, done: !t.done } : t));
    case "removed":
      return tasks.filter((t) => t.id !== action.id);
  }
}

interface TasksValue {
  tasks: Task[];
  addTask: (task: NewTask) => void;
  toggleTask: (task: Task) => void;
  removeTask: (id: number) => void;
}

const TasksContext = createContext<TasksValue | null>(null);

  const [tasks, dispatch] = useReducer(tasksReducer, sampleTasks);

  const value: TasksValue = {
    tasks,
    addTask: (task) => dispatch({ type: "added", task }),
    toggleTask: (task) => dispatch({ type: "toggled", id: task.id }),
    removeTask: (id) => dispatch({ type: "removed", id }),
  };

  return <TasksContext.Provider value={value}>{children}</TasksContext.Provider>;
}

  const ctx = useContext(TasksContext);
  if (!ctx) throw new Error("useTasks must be used inside <TasksProvider>");
  return ctx;
}
`,
    "TaskCard.tsx": `import { memo } from "react";

interface TaskCardProps {
  task: Task;
  onToggle: (task: Task) => void;
  onRemove: (id: number) => void;
}

function TaskCard({ task, onToggle, onRemove }: TaskCardProps) {
  return (
    <div className={task.done ? "task done" : "task"}>
      <input type="checkbox" checked={task.done} onChange={() => onToggle(task)} />
      <span className="title">{task.title}</span>
      <Button variant="danger" onClick={() => onRemove(task.id)}>Delete</Button>
    </div>
  );
}

`,
    "Button.tsx": `import type { ReactNode } from "react";

interface ButtonProps {
  children: ReactNode;
  onClick?: () => void;
  variant?: "primary" | "danger" | "plain";
  type?: "button" | "submit";
}

  return (
    <button type={type} className={"btn " + variant} onClick={onClick}>
      {children}
    </button>
  );
}
`,
    "PriorityBadge.tsx": `import type { Priority } from "./types";

  return <span className={"badge " + priority}>{priority}</span>;
}
`,
    "types.ts": `export type Priority = "low" | "medium" | "high";

  id: number;
  title: string;
  priority: Priority;
  done: boolean;
}

// A task before the server gives it an id
`,
    "data.ts": `import type { Task } from "./types";

  { id: 1, title: "Order new books", priority: "high", done: false },
  { id: 2, title: "Update the price list", priority: "medium", done: true },
  { id: 3, title: "Clean the shelves", priority: "low", done: false },
];
`,
    "styles.css": `.app { max-width: 480px; font-family: sans-serif; }
h1 { margin: 0 0 4px; color: #2b59c3; }
nav { display: flex; gap: 8px; margin: 8px 0 14px; }
nav a { color: #2b59c3; text-decoration: none; padding: 4px 10px; border-radius: 6px; }
nav a.active { background: #2b59c3; color: white; }
.btn { padding: 4px 10px; border-radius: 6px; border: 1px solid #bbb; background: white; cursor: pointer; }
.btn.primary { background: #2b59c3; border-color: #2b59c3; color: white; }
.btn.danger { border-color: #d33; color: #d33; }
.badge { padding: 1px 8px; border-radius: 10px; font-size: 12px; }
.badge.high { background: #fde2e2; color: #b42318; }
.badge.medium { background: #fff3cd; color: #8a6d00; }
.badge.low { background: #e3f6e8; color: #1e7b34; }
.task { display: flex; align-items: center; gap: 8px; padding: 8px; border: 1px solid #ddd; border-radius: 8px; margin-bottom: 6px; }
.task .title { flex: 1; }
.task.done .title { text-decoration: line-through; color: #888; }
.filters { display: flex; gap: 6px; margin-bottom: 10px; }
form { display: grid; gap: 8px; margin-bottom: 12px; }
input, select { padding: 6px; }
.error { color: #b42318; margin: 0; }
.bar { height: 10px; background: #eee; border-radius: 5px; }
.bar div { height: 10px; background: seagreen; border-radius: 5px; }
`,
    "main.tsx": `import { createRoot } from "react-dom/client";

createRoot(document.getElementById("root")!).render(<App />);
`,
  }}
/>

Tick a checkbox. The header count changes too, because `Header` and `TaskList` read the same context.

## Creating, Validating, and Managing Tasks

Now users can add their own tasks. `TaskForm` is a controlled form: the input's value comes from state.

Before saving, `validateTitle` checks the title and returns an error message, or `""` when all is well:

- `if (text === "") return "Please enter a title.";` — the title cannot be empty.
- `if (text.length < 3) return "Title must be at least 3 characters.";` — too short.

`validateTitle` is a plain function outside the component, so it is easy to read and to test.

The list also gets **filters**: All, Active and Done. The filter is local state in the list, because no other component needs it.

    filter === "all" ? true : filter === "done" ? t.done : !t.done,
  );

  return (
    <div className="app">
      <h1>Task Manager</h1>
      <div className="filters">
        {(["all", "active", "done"] as Filter[]).map((f) => (
          <button key={f} className={f === filter ? "btn primary" : "btn"} onClick={() => setFilter(f)}>
            {f}
          </button>
        ))}
      </div>
      {visible.map((t: Task) => (
      ))}
    </div>
  );
}

  return (
  );
}
`,
    "TaskForm.tsx": `import { useState, type FormEvent } from "react";

  const text = title.trim();
  if (text === "") return "Please enter a title.";
  if (text.length < 3) return "Title must be at least 3 characters.";
  if (text.length > 50) return "Title must be 50 characters or less.";
  return "";
}

  const [title, setTitle] = useState("");
  const [priority, setPriority] = useState<Priority>("medium");
  const [error, setError] = useState("");

  async function handleSubmit(e: FormEvent) {
    e.preventDefault();
    const message = validateTitle(title);
    setError(message);
    if (message) return;
    await onAdd({ title: title.trim(), priority, done: false });
    setTitle("");
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="What needs doing?" />
      <select value={priority} onChange={(e) => setPriority(e.target.value as Priority)}>
        <option value="low">low</option>
        <option value="medium">medium</option>
        <option value="high">high</option>
      </select>
      {error && <p className="error">{error}</p>}
      <Button type="submit" variant="primary">Add task</Button>
    </form>
  );
}
`,
    "TasksContext.tsx": `import { createContext, useContext, useReducer, type ReactNode } from "react";

type Action =
  | { type: "added"; task: NewTask }
  | { type: "toggled"; id: number }
  | { type: "removed"; id: number };

function tasksReducer(tasks: Task[], action: Action): Task[] {
  switch (action.type) {
    case "added":
      return [...tasks, { ...action.task, id: Date.now() }];
    case "toggled":
      return tasks.map((t) => (t.id === action.id ? { ...t, done: !t.done } : t));
    case "removed":
      return tasks.filter((t) => t.id !== action.id);
  }
}

interface TasksValue {
  tasks: Task[];
  addTask: (task: NewTask) => void;
  toggleTask: (task: Task) => void;
  removeTask: (id: number) => void;
}

const TasksContext = createContext<TasksValue | null>(null);

  const [tasks, dispatch] = useReducer(tasksReducer, sampleTasks);

  const value: TasksValue = {
    tasks,
    addTask: (task) => dispatch({ type: "added", task }),
    toggleTask: (task) => dispatch({ type: "toggled", id: task.id }),
    removeTask: (id) => dispatch({ type: "removed", id }),
  };

  return <TasksContext.Provider value={value}>{children}</TasksContext.Provider>;
}

  const ctx = useContext(TasksContext);
  if (!ctx) throw new Error("useTasks must be used inside <TasksProvider>");
  return ctx;
}
`,
    "TaskCard.tsx": `import { memo } from "react";

interface TaskCardProps {
  task: Task;
  onToggle: (task: Task) => void;
  onRemove: (id: number) => void;
}

function TaskCard({ task, onToggle, onRemove }: TaskCardProps) {
  return (
    <div className={task.done ? "task done" : "task"}>
      <input type="checkbox" checked={task.done} onChange={() => onToggle(task)} />
      <span className="title">{task.title}</span>
      <Button variant="danger" onClick={() => onRemove(task.id)}>Delete</Button>
    </div>
  );
}

`,
    "Button.tsx": `import type { ReactNode } from "react";

interface ButtonProps {
  children: ReactNode;
  onClick?: () => void;
  variant?: "primary" | "danger" | "plain";
  type?: "button" | "submit";
}

  return (
    <button type={type} className={"btn " + variant} onClick={onClick}>
      {children}
    </button>
  );
}
`,
    "PriorityBadge.tsx": `import type { Priority } from "./types";

  return <span className={"badge " + priority}>{priority}</span>;
}
`,
    "types.ts": `export type Priority = "low" | "medium" | "high";

  id: number;
  title: string;
  priority: Priority;
  done: boolean;
}

// A task before the server gives it an id
`,
    "data.ts": `import type { Task } from "./types";

  { id: 1, title: "Order new books", priority: "high", done: false },
  { id: 2, title: "Update the price list", priority: "medium", done: true },
  { id: 3, title: "Clean the shelves", priority: "low", done: false },
];
`,
    "styles.css": `.app { max-width: 480px; font-family: sans-serif; }
h1 { margin: 0 0 4px; color: #2b59c3; }
nav { display: flex; gap: 8px; margin: 8px 0 14px; }
nav a { color: #2b59c3; text-decoration: none; padding: 4px 10px; border-radius: 6px; }
nav a.active { background: #2b59c3; color: white; }
.btn { padding: 4px 10px; border-radius: 6px; border: 1px solid #bbb; background: white; cursor: pointer; }
.btn.primary { background: #2b59c3; border-color: #2b59c3; color: white; }
.btn.danger { border-color: #d33; color: #d33; }
.badge { padding: 1px 8px; border-radius: 10px; font-size: 12px; }
.badge.high { background: #fde2e2; color: #b42318; }
.badge.medium { background: #fff3cd; color: #8a6d00; }
.badge.low { background: #e3f6e8; color: #1e7b34; }
.task { display: flex; align-items: center; gap: 8px; padding: 8px; border: 1px solid #ddd; border-radius: 8px; margin-bottom: 6px; }
.task .title { flex: 1; }
.task.done .title { text-decoration: line-through; color: #888; }
.filters { display: flex; gap: 6px; margin-bottom: 10px; }
form { display: grid; gap: 8px; margin-bottom: 12px; }
input, select { padding: 6px; }
.error { color: #b42318; margin: 0; }
.bar { height: 10px; background: #eee; border-radius: 5px; }
.bar div { height: 10px; background: seagreen; border-radius: 5px; }
`,
    "main.tsx": `import { createRoot } from "react-dom/client";

createRoot(document.getElementById("root")!).render(<App />);
`,
  }}
/>

Try to add "Hi". You see the error message. Then add "Call the publisher".

## Working with APIs

So far, tasks live only in memory. A real app saves them on a server. The playground has a pretend API at `https://api.bookcorner.dev/tasks` that supports GET, POST, PUT and DELETE.

All the fetch code lives in one file, `taskService.ts`:

- `request<T>` is one helper for every call. It sends JSON, checks `response.ok`, and throws an error when the server fails.
- `taskService.getAll()`, `create()`, `update()` and `remove()` each make one request.

The context now calls the service and keeps `loading` and `error` in its state. Components do not change at all: they still call `useTasks()`. That is the power of the layers.

      <h1>Task Manager</h1>
      {loading && <p>Loading tasks...</p>}
      {error && <p className="error">{error}</p>}
      {tasks.map((t: Task) => (
      ))}
    </div>
  );
}

  return (
  );
}
`,
    "TasksContext.tsx": `import { createContext, useCallback, useContext, useEffect, useReducer, type ReactNode } from "react";

interface State {
  tasks: Task[];
  loading: boolean;
  error: string;
}

type Action =
  | { type: "loaded"; tasks: Task[] }
  | { type: "failed"; error: string }
  | { type: "added"; task: Task }
  | { type: "updated"; task: Task }
  | { type: "removed"; id: number };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "loaded":
      return { tasks: action.tasks, loading: false, error: "" };
    case "failed":
      return { ...state, loading: false, error: action.error };
    case "added":
      return { ...state, tasks: [...state.tasks, action.task] };
    case "updated":
      return { ...state, tasks: state.tasks.map((t) => (t.id === action.task.id ? action.task : t)) };
    case "removed":
      return { ...state, tasks: state.tasks.filter((t) => t.id !== action.id) };
  }
}

interface TasksValue extends State {
  addTask: (task: NewTask) => Promise<void>;
  toggleTask: (task: Task) => void;
  removeTask: (id: number) => void;
}

const TasksContext = createContext<TasksValue | null>(null);

  const [state, dispatch] = useReducer(reducer, { tasks: [], loading: true, error: "" });

  useEffect(() => {
    taskService
      .getAll()
      .then((tasks) => dispatch({ type: "loaded", tasks }))
      .catch(() => dispatch({ type: "failed", error: "Could not load your tasks. Please try again." }));
  }, []);

  const addTask = useCallback(async (task: NewTask) => {
    const saved = await taskService.create(task);
    dispatch({ type: "added", task: saved });
  }, []);

  const toggleTask = useCallback(async (task: Task) => {
    try {
      const saved = await taskService.update({ ...task, done: !task.done });
      dispatch({ type: "updated", task: saved });
    } catch {
      dispatch({ type: "failed", error: "Could not save the change." });
    }
  }, []);

  const removeTask = useCallback(async (id: number) => {
    await taskService.remove(id);
    dispatch({ type: "removed", id });
  }, []);

  return (
      {children}
  );
}

  const ctx = useContext(TasksContext);
  if (!ctx) throw new Error("useTasks must be used inside <TasksProvider>");
  return ctx;
}
`,
    "taskService.ts": `import type { NewTask, Task } from "./types";

const API = "https://api.bookcorner.dev/tasks";

// One helper for every request: sends JSON, checks the status, reads the JSON
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
  const response = await fetch(url, {
    ...options,
    headers: { "Content-Type": "application/json" },
  });
  if (!response.ok) {
    throw new Error("Request failed with status " + response.status);
  }
  if (response.status === 204) return undefined as T;
  return response.json();
}

  getAll: () => request<Task[]>(API),
  create: (task: NewTask) => request<Task>(API, { method: "POST", body: JSON.stringify(task) }),
  update: (task: Task) => request<Task>(API + "/" + task.id, { method: "PUT", body: JSON.stringify(task) }),
  remove: (id: number) => request<void>(API + "/" + id, { method: "DELETE" }),
};
`,
    "TaskForm.tsx": `import { useState, type FormEvent } from "react";

  const text = title.trim();
  if (text === "") return "Please enter a title.";
  if (text.length < 3) return "Title must be at least 3 characters.";
  if (text.length > 50) return "Title must be 50 characters or less.";
  return "";
}

  const [title, setTitle] = useState("");
  const [priority, setPriority] = useState<Priority>("medium");
  const [error, setError] = useState("");

  async function handleSubmit(e: FormEvent) {
    e.preventDefault();
    const message = validateTitle(title);
    setError(message);
    if (message) return;
    await onAdd({ title: title.trim(), priority, done: false });
    setTitle("");
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="What needs doing?" />
      <select value={priority} onChange={(e) => setPriority(e.target.value as Priority)}>
        <option value="low">low</option>
        <option value="medium">medium</option>
        <option value="high">high</option>
      </select>
      {error && <p className="error">{error}</p>}
      <Button type="submit" variant="primary">Add task</Button>
    </form>
  );
}
`,
    "TaskCard.tsx": `import { memo } from "react";

interface TaskCardProps {
  task: Task;
  onToggle: (task: Task) => void;
  onRemove: (id: number) => void;
}

function TaskCard({ task, onToggle, onRemove }: TaskCardProps) {
  return (
    <div className={task.done ? "task done" : "task"}>
      <input type="checkbox" checked={task.done} onChange={() => onToggle(task)} />
      <span className="title">{task.title}</span>
      <Button variant="danger" onClick={() => onRemove(task.id)}>Delete</Button>
    </div>
  );
}

`,
    "Button.tsx": `import type { ReactNode } from "react";

interface ButtonProps {
  children: ReactNode;
  onClick?: () => void;
  variant?: "primary" | "danger" | "plain";
  type?: "button" | "submit";
}

  return (
    <button type={type} className={"btn " + variant} onClick={onClick}>
      {children}
    </button>
  );
}
`,
    "PriorityBadge.tsx": `import type { Priority } from "./types";

  return <span className={"badge " + priority}>{priority}</span>;
}
`,
    "types.ts": `export type Priority = "low" | "medium" | "high";

  id: number;
  title: string;
  priority: Priority;
  done: boolean;
}

// A task before the server gives it an id
`,
    "styles.css": `.app { max-width: 480px; font-family: sans-serif; }
h1 { margin: 0 0 4px; color: #2b59c3; }
nav { display: flex; gap: 8px; margin: 8px 0 14px; }
nav a { color: #2b59c3; text-decoration: none; padding: 4px 10px; border-radius: 6px; }
nav a.active { background: #2b59c3; color: white; }
.btn { padding: 4px 10px; border-radius: 6px; border: 1px solid #bbb; background: white; cursor: pointer; }
.btn.primary { background: #2b59c3; border-color: #2b59c3; color: white; }
.btn.danger { border-color: #d33; color: #d33; }
.badge { padding: 1px 8px; border-radius: 10px; font-size: 12px; }
.badge.high { background: #fde2e2; color: #b42318; }
.badge.medium { background: #fff3cd; color: #8a6d00; }
.badge.low { background: #e3f6e8; color: #1e7b34; }
.task { display: flex; align-items: center; gap: 8px; padding: 8px; border: 1px solid #ddd; border-radius: 8px; margin-bottom: 6px; }
.task .title { flex: 1; }
.task.done .title { text-decoration: line-through; color: #888; }
.filters { display: flex; gap: 6px; margin-bottom: 10px; }
form { display: grid; gap: 8px; margin-bottom: 12px; }
input, select { padding: 6px; }
.error { color: #b42318; margin: 0; }
.bar { height: 10px; background: #eee; border-radius: 5px; }
.bar div { height: 10px; background: seagreen; border-radius: 5px; }
`,
    "main.tsx": `import { createRoot } from "react-dom/client";

createRoot(document.getElementById("root")!).render(<App />);
`,
  }}
/>

Watch the console. Each action prints a line like `[API] PUT /tasks/1 → 200`. The pretend server starts fresh on every Run, just like a test database.

## Handling Errors, Optimizing Performance, and Final Review

The last step joins everything into the finished app, and adds the finishing touches from Chapter 9:

- **Errors.** The context catches failed requests and stores a friendly message. An `ErrorBoundary` around the pages shows a fallback if a page crashes, while the header and nav keep working.
- **Performance.** `TaskCard` is wrapped in `memo`. The context functions use `useCallback`, so they stay the same and `memo` can skip cards that did not change. The filtered list uses `useMemo`.
- **Loading.** `StatsPage` is loaded with `lazy`, inside `Suspense`, so its code downloads only when someone opens it.

Here is the complete Task Manager. Open every tab and follow how the parts connect.

      <h1>Task Manager</h1>
      <p className="left">{left} tasks left</p>
    </header>
  );
}

  return (
        <div className="app">
          <nav>
            <NavLink to="/" end>Tasks</NavLink>
            <NavLink to="/new">New task</NavLink>
            <NavLink to="/stats">Stats</NavLink>
          </nav>
          <ErrorBoundary fallback={<p className="error">Sorry, this page could not be shown.</p>}>
            <Suspense fallback={<p>Loading page...</p>}>
                <Route path="/" element={<TaskListPage />} />
                <Route path="/new" element={<NewTaskPage />} />
                <Route path="/stats" element={<StatsPage />} />
                <Route path="*" element={<p>Page not found.</p>} />
        </div>
  );
}
`,
    "TasksContext.tsx": `import { createContext, useCallback, useContext, useEffect, useReducer, type ReactNode } from "react";

interface State {
  tasks: Task[];
  loading: boolean;
  error: string;
}

type Action =
  | { type: "loaded"; tasks: Task[] }
  | { type: "failed"; error: string }
  | { type: "added"; task: Task }
  | { type: "updated"; task: Task }
  | { type: "removed"; id: number };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "loaded":
      return { tasks: action.tasks, loading: false, error: "" };
    case "failed":
      return { ...state, loading: false, error: action.error };
    case "added":
      return { ...state, tasks: [...state.tasks, action.task] };
    case "updated":
      return { ...state, tasks: state.tasks.map((t) => (t.id === action.task.id ? action.task : t)) };
    case "removed":
      return { ...state, tasks: state.tasks.filter((t) => t.id !== action.id) };
  }
}

interface TasksValue extends State {
  addTask: (task: NewTask) => Promise<void>;
  toggleTask: (task: Task) => void;
  removeTask: (id: number) => void;
}

const TasksContext = createContext<TasksValue | null>(null);

  const [state, dispatch] = useReducer(reducer, { tasks: [], loading: true, error: "" });

  useEffect(() => {
    taskService
      .getAll()
      .then((tasks) => dispatch({ type: "loaded", tasks }))
      .catch(() => dispatch({ type: "failed", error: "Could not load your tasks. Please try again." }));
  }, []);

  const addTask = useCallback(async (task: NewTask) => {
    const saved = await taskService.create(task);
    dispatch({ type: "added", task: saved });
  }, []);

  const toggleTask = useCallback(async (task: Task) => {
    try {
      const saved = await taskService.update({ ...task, done: !task.done });
      dispatch({ type: "updated", task: saved });
    } catch {
      dispatch({ type: "failed", error: "Could not save the change." });
    }
  }, []);

  const removeTask = useCallback(async (id: number) => {
    await taskService.remove(id);
    dispatch({ type: "removed", id });
  }, []);

  return (
      {children}
  );
}

  const ctx = useContext(TasksContext);
  if (!ctx) throw new Error("useTasks must be used inside <TasksProvider>");
  return ctx;
}
`,
    "taskService.ts": `import type { NewTask, Task } from "./types";

const API = "https://api.bookcorner.dev/tasks";

// One helper for every request: sends JSON, checks the status, reads the JSON
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
  const response = await fetch(url, {
    ...options,
    headers: { "Content-Type": "application/json" },
  });
  if (!response.ok) {
    throw new Error("Request failed with status " + response.status);
  }
  if (response.status === 204) return undefined as T;
  return response.json();
}

  getAll: () => request<Task[]>(API),
  create: (task: NewTask) => request<Task>(API, { method: "POST", body: JSON.stringify(task) }),
  update: (task: Task) => request<Task>(API + "/" + task.id, { method: "PUT", body: JSON.stringify(task) }),
  remove: (id: number) => request<void>(API + "/" + id, { method: "DELETE" }),
};
`,
    "TaskListPage.tsx": `import { useMemo, useState } from "react";

type Filter = "all" | "active" | "done";

  const { tasks, loading, error, toggleTask, removeTask } = useTasks();
  const [filter, setFilter] = useState<Filter>("all");

  const visible = useMemo(
    () => tasks.filter((t: Task) => (filter === "all" ? true : filter === "done" ? t.done : !t.done)),
    [tasks, filter],
  );

  if (loading) return <p>Loading tasks...</p>;
  if (error) return <p className="error">{error}</p>;

  return (
    <div>
      <div className="filters">
        {(["all", "active", "done"] as Filter[]).map((f) => (
          <button key={f} className={f === filter ? "btn primary" : "btn"} onClick={() => setFilter(f)}>
            {f}
          </button>
        ))}
      </div>
      {visible.length === 0 && <p>No tasks here.</p>}
      {visible.map((t: Task) => (
      ))}
    </div>
  );
}
`,
    "NewTaskPage.tsx": `import { useNavigate } from "react-router-dom";

  const { addTask } = useTasks();
  const navigate = useNavigate();

  async function handleAdd(task: NewTask) {
    await addTask(task);
    navigate("/"); // go back to the list after saving
  }

  return (
    <div>
      <h2>New task</h2>
    </div>
  );
}
`,
    "StatsPage.tsx": `import type { Task } from "./types";

  const { tasks } = useTasks();
  const done = tasks.filter((t: Task) => t.done).length;
  const percent = tasks.length ? Math.round((done / tasks.length) * 100) : 0;

  return (
    <div>
      <h2>Stats</h2>
      <p>
        {done} of {tasks.length} tasks done ({percent}%)
      </p>
      <div className="bar">
        <div style={{ width: percent + "%" }} />
      </div>
    </div>
  );
}
`,
    "TaskForm.tsx": `import { useState, type FormEvent } from "react";

  const text = title.trim();
  if (text === "") return "Please enter a title.";
  if (text.length < 3) return "Title must be at least 3 characters.";
  if (text.length > 50) return "Title must be 50 characters or less.";
  return "";
}

  const [title, setTitle] = useState("");
  const [priority, setPriority] = useState<Priority>("medium");
  const [error, setError] = useState("");

  async function handleSubmit(e: FormEvent) {
    e.preventDefault();
    const message = validateTitle(title);
    setError(message);
    if (message) return;
    await onAdd({ title: title.trim(), priority, done: false });
    setTitle("");
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="What needs doing?" />
      <select value={priority} onChange={(e) => setPriority(e.target.value as Priority)}>
        <option value="low">low</option>
        <option value="medium">medium</option>
        <option value="high">high</option>
      </select>
      {error && <p className="error">{error}</p>}
      <Button type="submit" variant="primary">Add task</Button>
    </form>
  );
}
`,
    "TaskCard.tsx": `import { memo } from "react";

interface TaskCardProps {
  task: Task;
  onToggle: (task: Task) => void;
  onRemove: (id: number) => void;
}

function TaskCard({ task, onToggle, onRemove }: TaskCardProps) {
  return (
    <div className={task.done ? "task done" : "task"}>
      <input type="checkbox" checked={task.done} onChange={() => onToggle(task)} />
      <span className="title">{task.title}</span>
      <Button variant="danger" onClick={() => onRemove(task.id)}>Delete</Button>
    </div>
  );
}

`,
    "Button.tsx": `import type { ReactNode } from "react";

interface ButtonProps {
  children: ReactNode;
  onClick?: () => void;
  variant?: "primary" | "danger" | "plain";
  type?: "button" | "submit";
}

  return (
    <button type={type} className={"btn " + variant} onClick={onClick}>
      {children}
    </button>
  );
}
`,
    "PriorityBadge.tsx": `import type { Priority } from "./types";

  return <span className={"badge " + priority}>{priority}</span>;
}
`,
    "ErrorBoundary.tsx": `import { Component, type ErrorInfo, type ReactNode } from "react";

interface Props {
  children: ReactNode;
  fallback: ReactNode;
}

  state = { hasError: false };

  static getDerivedStateFromError() {
    return { hasError: true };
  }

  componentDidCatch(error: Error, info: ErrorInfo) {
    console.log("Caught: " + error.message);
  }

  render() {
    return this.state.hasError ? this.props.fallback : this.props.children;
  }
}
`,
    "types.ts": `export type Priority = "low" | "medium" | "high";

  id: number;
  title: string;
  priority: Priority;
  done: boolean;
}

// A task before the server gives it an id
`,
    "styles.css": `.app { max-width: 480px; font-family: sans-serif; }
h1 { margin: 0 0 4px; color: #2b59c3; }
nav { display: flex; gap: 8px; margin: 8px 0 14px; }
nav a { color: #2b59c3; text-decoration: none; padding: 4px 10px; border-radius: 6px; }
nav a.active { background: #2b59c3; color: white; }
.btn { padding: 4px 10px; border-radius: 6px; border: 1px solid #bbb; background: white; cursor: pointer; }
.btn.primary { background: #2b59c3; border-color: #2b59c3; color: white; }
.btn.danger { border-color: #d33; color: #d33; }
.badge { padding: 1px 8px; border-radius: 10px; font-size: 12px; }
.badge.high { background: #fde2e2; color: #b42318; }
.badge.medium { background: #fff3cd; color: #8a6d00; }
.badge.low { background: #e3f6e8; color: #1e7b34; }
.task { display: flex; align-items: center; gap: 8px; padding: 8px; border: 1px solid #ddd; border-radius: 8px; margin-bottom: 6px; }
.task .title { flex: 1; }
.task.done .title { text-decoration: line-through; color: #888; }
.filters { display: flex; gap: 6px; margin-bottom: 10px; }
form { display: grid; gap: 8px; margin-bottom: 12px; }
input, select { padding: 6px; }
.error { color: #b42318; margin: 0; }
.bar { height: 10px; background: #eee; border-radius: 5px; }
.bar div { height: 10px; background: seagreen; border-radius: 5px; }
`,
    "main.tsx": `import { createRoot } from "react-dom/client";

createRoot(document.getElementById("root")!).render(<App />);
`,
  }}
/>

### Final review checklist

Before you call a project finished, check:

- **It works.** Add, tick, delete and filter tasks. Visit every page, and an unknown URL.
- **Bad input is handled.** Empty and short titles show a clear message.
- **Failures are handled.** Loading and error messages show. A crash in one page does not blank the whole app.
- **The code is tidy.** Each file has one job. Types are used everywhere. No API URL is copied into components.
- **It is fast enough.** No large wasted re-renders, and rare pages load lazily.

Easy Way to Remember: types first, then parts, pages, state, forms, API, and finally polish.

### Ideas to go further

- Add a due date to each task and sort by it.
- Add an edit page at `/tasks/:id/edit`.
- Add a login page and protect the pages, as in the User Access Control lesson.

## Download Project Files

The complete Task Manager is ready as a real Vite + React + TypeScript project. It has the same code as the final lab, split into a realistic folder structure, plus a small local API that saves your tasks in a file.

Download Capstone Project: [Download Task Management Project](/downloads/react/task-management-project.zip)

### What is inside

```
task-manager/
  package.json          scripts and dependencies
  vite.config.ts        Vite settings
  index.html            the page with <div id="root">
  .env                  VITE_API_URL=http://localhost:3001
  db.json               the tasks, saved by the local API
  src/
    main.tsx            createRoot + render
    App.tsx             providers, router and routes
    types/task.ts       the Task type
    components/         Button, PriorityBadge, TaskCard, TaskForm, Header, NavBar, ErrorBoundary
    pages/              TaskListPage, NewTaskPage, TaskDetailPage, StatsPage, NotFoundPage
    context/            TasksContext (useReducer + useTasks)
    services/           apiClient + taskService (all fetch calls)
    utils/              validateTask.ts
```

### How to download, set up and run

1. **Install Node.js** 18 or newer from nodejs.org, if you do not have it yet.
2. **Download** the ZIP file with the link above.
3. **Unzip** it. You get a folder called `task-manager`.
4. **Open a terminal** in that folder (in VS Code: File, Open Folder, then Terminal, New Terminal).
5. **Install** the packages:

```bash
npm install
```

6. **Start** the project:

```bash
npm run dev
```

`npm run dev` starts two things together: the React app at `http://localhost:5173` (it opens in your browser), and a small REST API at `http://localhost:3001/tasks`, made with json-server. Press `Ctrl + C` in the terminal to stop both.

Your tasks are saved in `db.json`. Open it while the app runs and watch it change when you add, tick or delete a task. To start again, change it back to the three sample tasks.

## Common mistakes

<div className="tscroll">

| Mistake | What you see | Fix |
|---|---|---|
| Calling `fetch` inside many components | The same URL in many files, hard to change | Keep requests in `taskService.ts` |
| Using `useTasks()` outside `TasksProvider` | Error: useTasks must be used inside TasksProvider | Wrap the app in `<TasksProvider>` |
| Changing the task array directly with `push` | The screen does not update | Return a new array from the reducer |
| Saving a form without validation | Empty tasks in the list | Check the title with `validateTitle` first |
| Forgetting `Suspense` around a lazy page | An error when the page opens | Wrap `Routes` in `<Suspense fallback={...}>` |
| Building everything at once | Many bugs, hard to find | Build one step, run it, then add the next |

</div>

## Check Yourself

## Exercises

## What to Remember

- A capstone joins every skill into one app. Build it in small steps and run it after each one.
- Types first, then reusable components, routes, shared state in a context with a reducer, validated forms, and an API service.
- Finish with error handling, `memo`/`useCallback`/`lazy` where they help, and a final review checklist.

Next: Cheat Sheet
