098: Canonical Task Manager React Vertical Slice
Learning objective
Outcomes
You will combine components, props, lists, controlled forms, immutable state, conditional states, Effects, and Fetch into one small authenticated feature. The goal is to explain ownership and failure behavior, not to copy a visual layout.
I can trace a task from a form event to the API and back to an accessible rendered list.
Prerequisites
Complete 088–097. You should understand 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/fetch behavior.
Canonical project contract
Work in projects/task-manager. Its React client is in client/src, uses React 19 and Vite, and its API uses session cookies. The learner baseline deliberately returns { user }, { task }, and { tasks } rather than the later full-stack { data: ... } envelope.
The relevant endpoints are:
| 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 }. Do not silently change completed to done, invent a /api/auth/me route, or assume the later MongoDB contract. Read the project README before changing its checkpoint.
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
App owns committed server results because authentication, loading, and task mutations need them. The title field is local draft state. The list is derived from tasks; it is not a second state variable. Render calculates JSX, event handlers perform user-triggered writes, and the startup Effect restores the session. There is no Effect that copies tasks to tasks or submits a form because a boolean changed.
Complete vertical slice
This 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 API and Vite dev proxy are supplied by the canonical project.
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 real project currently uses the same endpoint and field contract in a compact App.jsx; compare this teaching version with it rather than blindly replacing the checkpoint. main.jsx mounts the app with createRoot(document.getElementById('root')) and React.StrictMode. styles.css supplies the established editorial layout and mobile breakpoint.
Failure and state table
| 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 canonical starter is intentionally small and does not yet model a separate loading state for every mutation. A production extension should add request status per operation, prevent double activation where necessary, and deliberately restore focus after deleting the focused row.
Debugging checklist
- A blank screen: inspect the browser console first, then check JSX syntax and import paths.
401or an empty list: inspect the Network panel, cookie/session response, API URL, and whether the server is running.400: compare the exact JSON body and the server validation rules; trim titles before sending.- 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 that the server response ID is preserved. - 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.
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 keyboard only. Run npm run build from projects/task-manager after changes.
Tiered exercises
Core: Rebuild local add, toggle, delete, empty, and authenticated/anonymous branches from the ownership diagram. Keep completed and stable IDs.
Stretch: Add a loading status for session restoration and separate pending IDs for toggle/delete. Disable only the operation currently in flight and retain the rest of the list.
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. 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 it in the event handler, clear it in both success and failure paths, and never infer loading from an empty array.
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 the committed task and 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 has explicit ownership: committed server data in the feature owner, drafts near their forms, derived views during render, user writes in event handlers, and external synchronization in cleaned-up Effects. The canonical task manager uses completed, session endpoints, stable task IDs, accessible controls, and server-confirmed mutations.
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 and can perform writes directly. The mount/session synchronization is an external read with 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 vertical slice deliberately uses local React state plus fetch so you can trace every transition. Later lessons will refactor the 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 important skill is to explain why ownership changes when the architecture changes.
Do not keep every abstraction “because it was introduced.” If TanStack Query owns remote task data, do not also mirror the same task collection in Redux and component state. If the URL owns the current page/filter, do not duplicate it into unrelated global state.
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 purpose of the first project is not to create the final production architecture. It is to make every ownership decision visible before abstractions hide the mechanics.
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 |
Then later versions change ownership deliberately.
Baseline project structure
src/ ├─ api/ │ └─ tasks.js ├─ components/ │ ├─ TaskForm.jsx │ ├─ TaskList.jsx │ ├─ TaskRow.jsx │ └─ TaskStatus.jsx ├─ pages/ │ └─ TasksPage.jsx ├─ App.jsx └─ main.jsx
Do not build a giant "services/utils/hooks/components" architecture before the application has enough complexity to justify it.
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 keeps HTTP parsing/error normalization out of UI components.
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 domain operations.
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 is intentionally verbose. Later TanStack Query removes most of this because it has a dedicated server-state lifecycle.
Create workflow
async function handleCreate(input) {
const result = await createTask(input);
setState((current) => ({
...current,
tasks: [...current.tasks, result.task],
}));
}
Question:
What if the server list is sorted newest-first?
Then appending may disagree with server truth.
You could:
- insert according to the contract;
- refetch;
- later invalidate query cache;
- update cache from authoritative response.
This simple example reveals why server-state management becomes more sophisticated.
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 rather than assuming the client prediction is authoritative.
Delete workflow
async function handleDelete(id) {
await deleteTask(id);
setState((current) => ({
...current,
tasks: current.tasks.filter((task) => task.id !== id),
}));
}
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/API decisions.
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 />;
}
Do not show "No tasks" while loading.
Do not expose raw stack traces to users.
Optimistic toggle experiment
Before TanStack Query, implement optimism manually to understand the mechanics.
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;
}
}
Then identify flaws:
- concurrent toggles;
- rollback overwriting newer changes;
- multiple projections;
- shared consumers;
- stale list;
- race with refetch.
This motivates the mutation/cache model later.
Project debugging checklist
When the screen is wrong, separate layers.
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?
Refactoring milestone
After the vertical slice works, create a short design note:
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.
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.
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.
