FullStack Course LogoFullStack Course
Module: React and Ecosystem
React and Ecosystem·098·14 MIN READ

098: Canonical Task Manager React Vertical Slice

TOPICS COVERED: 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.

ActionRequestSuccessful response
Restore sessionGET /api/me`{ user: object
Sign in/registerPOST /api/auth/login or /register with { email, password }{ user }
Load tasksGET /api/tasks{ tasks }
AddPOST /api/tasks with { title }{ task }
TogglePATCH /api/tasks/:id with { completed }{ task }
DeleteDELETE /api/tasks/:id204 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

text
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.

jsx
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.

SituationState/conditionRequired behavior
Session check pendinginitial requestDo not pretend the user is authenticated; show a status or intentional shell
Anonymoususer === nullShow labeled auth controls
Auth/API failureerrorPreserve useful input and show an alert with recovery context
Authenticated, zero taskstasks.length === 0Show a specific empty message and add form
Authenticated, tasksnon-empty arrayRender each record with key={task.id}
Toggle/delete failurerequest rejectedKeep 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.
  • 401 or 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/catch around every user-triggered async action and handle startup rejection.
  • Keyboard failure: use real buttons, visible labels, unique names, and :focus-visible styles.

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

  1. Which values are facts, drafts, derived values, or external-system status?
  2. What exact request and response does add/toggle/delete use?
  3. 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

Interview questions

  1. Why is a task mutation an event while session restoration is an Effect?
  2. Which state would you move down if typing in the form made the whole app slow?
  3. 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:

ConcernOwner in this lesson
taskscomponent state
task API callsrequest helper
loading/errorcomponent state
form draftform/component
selected taskcomponent state
routingnot yet introduced
cache freshnessnot yet introduced
authorizationserver/API
retrymanual
cancellationAbortController

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

text
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

jsx
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

jsx
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

jsx
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

jsx
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

jsx
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

jsx
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

jsx
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.

jsx
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.

text
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

  1. Implement list/create/update/delete with the API helper.
  2. Add AbortController for initial load.
  3. Implement success-empty separately from pending.
  4. Add manual optimistic toggle and force a 500 rollback.
  5. Simulate two fast toggles and document the race.
  6. Write an ownership table for the project.
  7. 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.

Reader page: /react/lesson/098/canonical-task-manager-react-vertical-slice