098: Canonical Task Manager React Vertical Slice
Learning objective
Outcomes
This lesson brings several React decisions together in one small authenticated feature: components, props, lists, controlled forms, immutable state updates, explicit conditional states, Effects, and Fetch. The point is not to reproduce a particular visual design. The point is to be able to explain who owns each value, which operation changes it, and what the user sees when the network succeeds or fails.
By the end, you should be able to trace a task all the way from a form event, through the API request, and back to an accessible rendered list.
Prerequisites
Complete 088–097 first. You should already be comfortable with render versus commit, modules and roots, composition, props and children, stable keys, explicit UI states, useState, event ownership, controlled inputs, Effect cleanup, and basic HTTP, JSON, and fetch behavior.
Canonical project contract
Work in projects/task-manager. The React client lives in client/src, uses React 19 and Vite, and talks to an API that uses session cookies. The learner baseline intentionally returns { user }, { task }, and { tasks }; it does not yet use the later full-stack { data: ... } envelope.
Use the contract below as the source of truth while working. A response shape that looks more familiar from another project is still the wrong response shape here.
| Action | Request | Successful response |
|---|---|---|
| Restore session | GET /api/me | `{ user: object |
| Sign in/register | POST /api/auth/login or /register with { email, password } | { user } |
| Load tasks | GET /api/tasks | { tasks } |
| Add | POST /api/tasks with { title } | { task } |
| Toggle | PATCH /api/tasks/:id with { completed } | { task } |
| Delete | DELETE /api/tasks/:id | 204 No Content |
The canonical record shape is { id, title, completed }. Keep that vocabulary throughout the slice. Do not silently rename completed to done, invent an /api/auth/me route, or assume the later MongoDB contract. Before changing a project checkpoint, read the project README so that the lesson and the starter remain aligned.
Architecture mental model
main.jsx -> StrictMode -> App
App: user, tasks, auth fields, task draft, error
├─ Sign-in/register view
└─ Task view
├─ Header and sign out
├─ Add-task form
├─ Task list -> repeated task row
└─ Empty/error output
The useful ownership rule is simple: App owns committed server results because authentication, loading, and task mutations all depend on them. The title field is a local draft owned near the add form. The rendered list is derived from tasks, so it is not another state variable that can drift out of sync.
Render calculates JSX. Event handlers perform writes that represent user intent. The startup Effect restores the session, which is synchronization with an external system. There is no Effect that copies tasks into another tasks value, and there is no Effect that submits a form merely because a boolean changed. Those distinctions become valuable when debugging stale UI and duplicate requests.
Complete vertical slice
The following is the smallest runnable shape for client/src/App.jsx. It matches the baseline response names and can be expanded with the project stylesheet. The canonical project supplies the API and the Vite development proxy.
import { useEffect, useState } from 'react';
async function request(path, options = {}) {
const response = await fetch(path, {
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', ...options.headers },
...options,
});
if (!response.ok) {
let message = 'Request failed.';
try {
const body = await response.json();
message = body.error || message;
} catch {
// A non-JSON error response is still an HTTP failure.
}
throw new Error(message);
}
return response.status === 204 ? null : response.json();
}
function TaskList({ tasks, onToggle, onDelete }) {
if (tasks.length === 0) {
return <p className="empty">Nothing here yet. Start with the smallest useful step.</p>;
}
return (
<ul>
{tasks.map((task) => (
<li key={task.id} className={task.completed ? 'complete' : ''}>
<button
type="button"
className="check"
aria-label={`Mark ${task.title} ${task.completed ? 'open' : 'complete'}`}
onClick={() => onToggle(task)}
>
{task.completed ? '✓' : ''}
</button>
<span>{task.title}</span>
<button
type="button"
className="delete"
aria-label={`Delete ${task.title}`}
onClick={() => onDelete(task.id)}
>
Delete
</button>
</li>
))}
</ul>
);
}
function AuthForm({ onAuthenticate, error }) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
function submit(event, action) {
event.preventDefault();
onAuthenticate(action, { email, password });
}
return (
<form>
<h2>Enter your workspace</h2>
<label htmlFor="email">Email</label>
<input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
<label htmlFor="password">Password</label>
<input id="password" type="password" minLength={8} maxLength={128} value={password} onChange={(e) => setPassword(e.target.value)} required />
<button type="submit" onClick={(e) => submit(e, 'login')}>Sign in</button>
<button type="submit" onClick={(e) => submit(e, 'register')}>Create account</button>
{error && <p role="alert">{error}</p>}
</form>
);
}
export default function App() {
const [user, setUser] = useState(null);
const [tasks, setTasks] = useState([]);
const [title, setTitle] = useState('');
const [error, setError] = useState('');
async function loadTasks() {
const result = await request('/api/tasks');
setTasks(result.tasks);
}
useEffect(() => {
let active = true;
request('/api/me')
.then(({ user: current }) => {
if (!active || !current) return;
setUser(current);
return loadTasks();
})
.catch((cause) => active && setError(cause.message));
return () => { active = false; };
}, []);
async function authenticate(action, credentials) {
try {
const result = await request(`/api/auth/${action}`, {
method: 'POST', body: JSON.stringify(credentials),
});
setUser(result.user);
setError('');
await loadTasks();
} catch (cause) {
setError(cause.message);
}
}
async function addTask(event) {
event.preventDefault();
const cleanTitle = title.trim();
if (!cleanTitle) return setError('Enter a task title.');
try {
const { task } = await request('/api/tasks', {
method: 'POST', body: JSON.stringify({ title: cleanTitle }),
});
setTasks((current) => [task, ...current]);
setTitle('');
setError('');
} catch (cause) { setError(cause.message); }
}
async function toggleTask(task) {
try {
const { task: updated } = await request(`/api/tasks/${task.id}`, {
method: 'PATCH', body: JSON.stringify({ completed: !task.completed }),
});
setTasks((current) => current.map((item) => item.id === updated.id ? updated : item));
} catch (cause) { setError(cause.message); }
}
async function deleteTask(id) {
try {
await request(`/api/tasks/${id}`, { method: 'DELETE' });
setTasks((current) => current.filter((task) => task.id !== id));
} catch (cause) { setError(cause.message); }
}
async function logout() {
await request('/api/auth/logout', { method: 'POST' });
setUser(null);
setTasks([]);
}
if (!user) return <main><h1>Task Manager</h1><AuthForm onAuthenticate={authenticate} error={error} /></main>;
return (
<main>
<header><h1>Good work, {user.email.split('@')[0]}.</h1><button type="button" onClick={logout}>Sign out</button></header>
<form onSubmit={addTask}>
<label htmlFor="new-task">What needs your attention?</label>
<input id="new-task" value={title} onChange={(e) => setTitle(e.target.value)} />
<button type="submit">Add task</button>
</form>
{error && <p role="alert">{error}</p>}
<TaskList tasks={tasks} onToggle={toggleTask} onDelete={deleteTask} />
</main>
);
}
The project already has the same endpoint and field contract in a compact App.jsx. Compare this teaching version with that file instead of blindly replacing the checkpoint. main.jsx mounts the application with createRoot(document.getElementById('root')) and React.StrictMode. styles.css provides the established editorial layout and mobile breakpoint.
There are several mechanics worth tracing in the example. The request helper sends the session cookie, treats any non-2xx response as an error, tolerates an error response that is not JSON, and does not try to parse a 204 No Content response as JSON. Add prepends the server-returned task. Toggle replaces the matching record with the server-returned record. Delete removes a record only after the server confirms success. Each update uses an immutable array operation, so React receives a new array reference without mutating the previous state.
Failure and state table
The UI must distinguish an empty successful result from a request that has not completed. It must also distinguish a failed mutation from a mutation that has been confirmed. Use this table as a behavioral contract.
| Situation | State/condition | Required behavior |
|---|---|---|
| Session check pending | initial request | Do not pretend the user is authenticated; show a status or intentional shell |
| Anonymous | user === null | Show labeled auth controls |
| Auth/API failure | error | Preserve useful input and show an alert with recovery context |
| Authenticated, zero tasks | tasks.length === 0 | Show a specific empty message and add form |
| Authenticated, tasks | non-empty array | Render each record with key={task.id} |
| Toggle/delete failure | request rejected | Keep the prior task state; do not remove or mark success before the server confirms |
The starter is deliberately small and does not model a separate loading state for every mutation. That is a limitation of the teaching baseline, not a recommendation that production interfaces ignore request status. A production extension should add status per operation, prevent double activation where appropriate, and deliberately restore focus after deleting the row that held focus.
Debugging checklist
Use the symptom to choose the boundary to inspect instead of changing components at random:
- A blank screen: inspect the browser console first, then check JSX syntax and import paths.
401or an empty list: inspect the Network panel, the cookie/session response, the API URL, and whether the server is running.400: compare the exact JSON body with the server validation rules; trim titles before sending them.- A task disappears after a failed delete: move the state update after the awaited successful response.
- The wrong row changes: verify
key={task.id}and confirm that the server response preserves the record ID. - Stale UI after auth: clear tasks on logout and load tasks only after a confirmed session.
- Unhandled promise: put
try/catcharound every user-triggered async action and handle startup rejection. - Keyboard failure: use real buttons, visible labels, unique names, and
:focus-visiblestyles.
Exercise the boundaries deliberately. Use the Network panel with throttling, turn the API off, return 500, submit invalid credentials, create two tasks, toggle the first, delete the first, and reload. Test at a narrow viewport and with the keyboard only. After changes, run npm run build from projects/task-manager. A normal build result confirms that the production bundle can be generated; it does not replace browser, accessibility, or failure-path testing.
Tiered exercises
Core: Rebuild local add, toggle, delete, empty, and authenticated/anonymous branches from the ownership diagram. Keep the server field named completed and preserve stable IDs.
Stretch: Add a loading status for session restoration and separate pending IDs for toggle/delete. Disable only the operation currently in flight; the rest of the list should remain usable.
Challenge: Add editing with an editingId, a keyed controlled draft, server PATCH, field-error handling, and focus restoration. Document which values are server state, draft state, derived values, and Effect state.
Core solution: App owns user and committed tasks; TaskForm owns title; TaskList receives records and callback props. visibleTasks and counts remain derived rather than copied into state. Add uses [task, ...current], toggle uses map, and delete uses filter only after the server confirms success.
Stretch solution: use status: 'checking' | 'ready' | 'error' for session restoration and pendingAction: { type, id } | null for mutation feedback. Set the pending value in the event handler, clear it in both success and failure paths, and never infer loading from an empty array. An empty array can mean either a completed request with no records or a state that has not been loaded yet.
Challenge solution: derive editingTask with tasks.find, render <EditTaskForm key={editingTask.id} task={editingTask} />, keep the draft in that form, and replace the matching record with map only after PATCH succeeds. A failed request leaves both the committed task and the draft available for correction.
Exit questions
- Which values are facts, drafts, derived values, or external-system status?
- What exact request and response does add/toggle/delete use?
- How would you test the failure path without depending on public network uptime?
Recap
A coherent React vertical slice makes ownership explicit: committed server data belongs to the feature owner, drafts stay near their forms, derived views are calculated during render, user writes happen in event handlers, and external synchronization happens in cleaned-up Effects. In this task manager, that means keeping completed, the session endpoints, stable task IDs, accessible controls, and server-confirmed mutations consistent from the API boundary through the rendered UI.
Official references
- React: Thinking in React
- React: Managing State
- React: Synchronizing with Effects
- React:
createRoot - Vite: Building for Production
- MDN: Fetch API
Interview questions
- Why is a task mutation an event while session restoration is an Effect?
- Which state would you move down if typing in the form made the whole app slow?
- How do you prevent a failed request from corrupting optimistic local state?
Strong answer: Events represent user intent, so they can perform writes directly. Mount-time session restoration is an external read and needs cleanup. Keep draft state local, update committed data from confirmed server responses, preserve stable IDs, and test visible behavior under network failure.
2026 depth expansion: use this vertical slice as a diagnostic, not the final architecture
The local-state-and-fetch implementation is intentionally transparent. It lets you trace every transition before a library hides the lifecycle. Later lessons refactor this same domain through:
- reducer/context when client transitions become complex;
- React Router for URL ownership and route boundaries;
- TanStack Query v5 for server-state caching and mutations;
- React Hook Form + Zod for larger forms;
- Suspense/Error Boundaries for loading and failure containment;
- tests at component, network, and browser boundaries.
The durable skill is explaining why ownership changes when the architecture changes. A new abstraction is useful only when it takes responsibility for a problem that is now real.
Do not keep every abstraction simply because an earlier lesson introduced it. If TanStack Query owns remote task data, do not also mirror that same task collection in Redux and component state. If the URL owns the current page or filter, do not duplicate it in unrelated global state. Duplication creates more synchronization paths, and each path is another place for stale data to appear.
This project becomes the comparison point for the advanced half of the React curriculum.
Deep dive: turn the vertical slice into an architecture map
The first project is not supposed to be the final production architecture. Its value is that each ownership decision remains visible before abstractions hide the mechanics. Once the slice works, write down those decisions explicitly. That record gives you something concrete to compare when a router, cache, reducer, or form library takes over part of the job.
Create an explicit table:
| Concern | Owner in this lesson |
|---|---|
| tasks | component state |
| task API calls | request helper |
| loading/error | component state |
| form draft | form/component |
| selected task | component state |
| routing | not yet introduced |
| cache freshness | not yet introduced |
| authorization | server/API |
| retry | manual |
| cancellation | AbortController |
Later versions change these owners deliberately. For example, a query library can own freshness and refetching, but it cannot decide which task the product considers selected unless that responsibility is assigned to it or to another layer.
Baseline project structure
src/
├─ api/
│ └─ tasks.js
├─ components/
│ ├─ TaskForm.jsx
│ ├─ TaskList.jsx
│ ├─ TaskRow.jsx
│ └─ TaskStatus.jsx
├─ pages/
│ └─ TasksPage.jsx
├─ App.jsx
└─ main.jsx
This structure is a possible baseline for the expanded version, not a requirement to create every file immediately. Do not build a giant services/utils/hooks/components architecture before the application has enough complexity to justify each boundary. A directory is not an architecture by itself; the useful question is which responsibility each module owns.
API helper
export async function request(path, options = {}) {
const response = await fetch(path, {
...options,
headers: {
Accept: 'application/json',
...options.headers,
},
});
let body = null;
if (response.status !== 204) {
body = await response.json();
}
if (!response.ok) {
const error = new Error(
body?.error?.message ?? `HTTP ${response.status}`,
);
error.status = response.status;
error.body = body;
throw error;
}
return body;
}
This helper keeps HTTP parsing and error normalization out of UI components. It also preserves useful diagnostic information: callers can inspect the status and parsed response body rather than receiving only an unstructured failure. As with the earlier helper, the 204 check matters because a no-content response is successful but has no JSON document to parse.
Task API module
import { request } from './request.js';
export function listTasks({ signal } = {}) {
return request('/api/tasks', { signal });
}
export function createTask(input) {
return request('/api/tasks', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(input),
});
}
export function updateTask(id, input) {
return request(`/api/tasks/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(input),
});
}
export function deleteTask(id) {
return request(`/api/tasks/${id}`, {
method: 'DELETE',
});
}
The UI now speaks in domain operations: list, create, update, and delete. URL construction, HTTP methods, headers, and serialization live in one API boundary. That separation does not eliminate error handling; it makes the error handling easier to apply consistently.
Manual loading with cancellation
function TasksPage() {
const [state, setState] = useState({
status: 'pending',
tasks: [],
error: null,
});
useEffect(() => {
const controller = new AbortController();
async function load() {
setState({
status: 'pending',
tasks: [],
error: null,
});
try {
const data = await listTasks({
signal: controller.signal,
});
setState({
status: 'success',
tasks: data.tasks,
error: null,
});
} catch (error) {
if (error.name === 'AbortError') return;
setState({
status: 'error',
tasks: [],
error,
});
}
}
load();
return () => controller.abort();
}, []);
This code is intentionally verbose. The status value distinguishes pending, success, and failure, while AbortController stops the request when the Effect is cleaned up. An aborted request is an expected lifecycle outcome, not a user-facing load error. Later, TanStack Query supplies a dedicated server-state lifecycle and removes most of this manual bookkeeping.
Create workflow
async function handleCreate(input) {
const result = await createTask(input);
setState((current) => ({
...current,
tasks: [...current.tasks, result.task],
}));
}
The update waits for the server response and then creates a new state object and task array. There is a subtle product/API question here: what ordering does the server promise?
Question:
What if the server list is sorted newest-first?
Then appending may disagree with server truth. The local result is not automatically wrong, but it may show an ordering that a later reload reverses.
You could:
- insert according to the contract;
- refetch;
- later invalidate query cache;
- update cache from authoritative response.
This small example is useful precisely because it exposes why server-state management becomes more sophisticated. The write itself is easy; keeping every view consistent with server ordering, freshness, and concurrent changes is the harder problem.
Update workflow
async function handleToggle(task) {
const result = await updateTask(task.id, {
completed: !task.completed,
});
setState((current) => ({
...current,
tasks: current.tasks.map((item) =>
item.id === result.task.id ? result.task : item,
),
}));
}
This uses the server response instead of treating the client's prediction as authoritative. The client asks for the opposite completed value, but the server's returned record is what replaces the matching item. That leaves room for server-side normalization, authorization, or other rules without silently keeping a client-only version.
Delete workflow
async function handleDelete(id) {
await deleteTask(id);
setState((current) => ({
...current,
tasks: current.tasks.filter((task) => task.id !== id),
}));
}
Here too, filtering occurs only after deleteTask resolves. The code does not claim that the row is gone before the server confirms the deletion.
Production questions:
- What if delete fails after UI disabled the row?
- Should deletion be optimistic?
- Is undo required?
- What if the resource is already deleted?
- Is 404 after retry equivalent to success?
- Does server require version/conflict token?
These are product and API decisions, not details that a generic UI helper can answer. The right behavior depends on whether the operation is reversible, whether another user can change the record, and what the API means by each status.
Empty and error states
if (state.status === 'pending') {
return <TaskListSkeleton />;
}
if (state.status === 'error') {
return (
<section role="alert">
<h2>Tasks could not load</h2>
<p>{getUserMessage(state.error)}</p>
<button type="button" onClick={retry}>
Try again
</button>
</section>
);
}
if (state.tasks.length === 0) {
return <EmptyTasks />;
}
The order of these branches carries meaning. A pending request is not an empty result, and a technical error should not be presented as “no tasks.” The retry action also needs a user-oriented message rather than a raw stack trace.
Do not show “No tasks” while loading.
Do not expose raw stack traces to users.
Optimistic toggle experiment
Before using TanStack Query, implement optimism manually once so that the mechanics are visible. Optimistic UI updates immediately, before the server confirms the change, so it must also define what happens on success, failure, and overlap with another operation.
async function handleToggle(task) {
const previous = task;
setState((current) => ({
...current,
tasks: current.tasks.map((item) =>
item.id === task.id
? { ...item, completed: !item.completed }
: item,
),
}));
try {
const result = await updateTask(task.id, {
completed: !task.completed,
});
setState((current) => ({
...current,
tasks: current.tasks.map((item) =>
item.id === result.task.id ? result.task : item,
),
}));
} catch (error) {
setState((current) => ({
...current,
tasks: current.tasks.map((item) =>
item.id === previous.id ? previous : item,
),
}));
throw error;
}
}
The rollback uses the earlier record, and a successful response still replaces the optimistic projection with server data. That is enough to demonstrate the basic pattern, but it is not a complete concurrency strategy.
Then identify the flaws:
- concurrent toggles;
- rollback overwriting newer changes;
- multiple projections;
- shared consumers;
- stale list;
- race with refetch.
For example, two quick toggles can make the second request depend on a state that the first request has not confirmed. A later rollback can then restore an older snapshot over a newer user action. These cases motivate the mutation and cache model introduced later.
Project debugging checklist
When the screen is wrong, separate the layers. A request can be correct while state is stale, or state can be correct while the render branch is wrong.
HTTP
- correct URL?
- method?
- request body?
- status?
- response body?
- CORS/auth cookies?
State
- owner?
- stale snapshot?
- mutation?
- duplicate copy?
Render
- correct branch?
- stable keys?
- null/undefined?
- component remount?
Accessibility
- keyboard?
- labels?
- error announcements?
- disabled behavior?
Performance
- duplicate request?
- list size?
- unnecessary high-level state?
This ordering gives you a practical investigation path: inspect the browser and Network panel, identify the state transition, then inspect the render and accessibility behavior. Check performance after correctness, but do not assume that a visually correct result is accessible or efficient.
Refactoring milestone
After the vertical slice works, write a short design note. The goal is to identify a responsibility that has become painful, not to demonstrate that you know a library.
What is painful now?
What repeated code exists?
What state does not belong locally?
What will routing solve?
What will Query solve?
What will form tooling solve?
What should remain simple?
Do not refactor because a library exists. Refactor because a responsibility has become clearer. If the current code is still easy to reason about, keeping it simple is a valid design decision.
Exercises
- Implement list/create/update/delete with the API helper.
- Add AbortController for initial load.
- Implement success-empty separately from pending.
- Add manual optimistic toggle and force a 500 rollback.
- Simulate two fast toggles and document the race.
- Write an ownership table for the project.
- Predict exactly what will change after moving to TanStack Query v5.
These exercises move from implementing the request boundary to handling lifecycle and failure behavior, then to analyzing ownership and concurrency. Keep the distinction between what the server owns and what the client is temporarily projecting as you work through them.
Mastery check
You should be able to explain:
- why this project intentionally uses some manual state;
- what problems a query cache will solve later;
- why server responses should usually be authoritative;
- why optimistic updates require reconciliation;
- how to debug by layer instead of randomly editing components.
If you can answer those questions and reproduce the failure paths, you understand the lesson's main architecture decision: start with explicit ownership and visible transitions, then move a responsibility to a higher-level abstraction only when the abstraction solves a demonstrated problem.
