Build an Incident Dashboard with React

Build a React incident dashboard with reducer-driven state and context.

Introduction

30 Second Summary

When a digital service breaks, people make decisions from whatever the screen tells them. Conflicting statuses can send attention toward the wrong problem.

In this project, you will build an incident operations dashboard with React. You will refactor a deliberately inconsistent screen into a synchronized system with a single source of truth.

What You'll Build

You will demo an on-call console where the entire screen stays synchronized through each incident update.

By the end of this project, you'll have:

  • Filter the incident queue by status to focus the dashboard on active work.
  • Select any incident to inspect its status in the details panel. Its severity and service ownership appear alongside it.
  • Move incidents through the response workflow while one reducer keeps every visible status synchronized.
  • Secret Mission: Add a severity filter that composes with the status filter while preserving a valid incident selection.

Are there any prerequisites?

You should have built a few React apps before starting. Familiarity with components, props, events, and basic state is enough.

Before We Start

This first checkpoint sets a clear goal before any hands-on work begins. You are committing to a dashboard that makes scalable React state architecture visible.

Set Up the React Workspace

Architecture changes are hard to trust when machines use different tool versions. A pinned workspace makes every later state experiment reproducible.

You will use Visual Studio Code to hold the local project. Vite will run the React starter screen.

The project pins React 19.3.1 with Vite 8.3.4. A Node.js compatibility check protects this baseline before any files depend on it.

In this step, get ready to:
  • Confirm that Node.js is compatible with the pinned Vite version.
  • Create the pinned project files.
  • Run the starter dashboard in your browser.
Confirm the Node.js baseline

Vite requires Node.js 20.19+ or 22.12+. Checking the runtime now keeps an incompatible version from causing confusing installation errors.

  • Press the Windows key to open Windows search.
  • Type Visual Studio Code into the search field.
  • Press Enter to open Visual Studio Code.
  • Click File in the top menu.
  • Select Open Folder.
  • Use the folder picker to create incident-operations-dashboard on your Desktop.
  • Select the incident-operations-dashboard folder.
  • Confirm the folder selection in the dialog.

Visual Studio Code now shows incident-operations-dashboard as the current workspace.

  • Click View in the top menu.
  • Select Terminal to open the integrated terminal at the workspace folder.
  • Check the installed Node.js version by running this command:
node --version

What does this command check?

The command prints the Node.js version available to the integrated terminal. You can compare that output with Vite's supported versions before installing the project packages.

✔️ I See a Compatible Version

Good. Your Node.js runtime meets Vite's requirement.

  • Keep the integrated terminal open for the next setup task.

ⓧ I See an Older Version

Updating the runtime can feel disruptive. The current Node.js LTS installer gives this project a compatible baseline.

The Windows installer may request permission to change your device. It also presents installation choices before copying the runtime.

  • Visit the official Node.js download page.
  • Download Node.js v24.22.0 LTS for Windows.
  • Run the downloaded Windows installer.
  • Keep the default installation choices.
  • Close Visual Studio Code after the installation completes.
  • Press the Windows key to open Windows search.
  • Type Visual Studio Code into the search field.
  • Press Enter to reopen Visual Studio Code.

The refreshed Visual Studio Code process can now detect the newly installed runtime.

  • Click File in the top menu.
  • Select Open Folder.
  • Choose the incident-operations-dashboard folder on your Desktop.
  • Select View in the top menu.
  • Select Terminal.
  • Confirm the updated Node.js version by running this command:
node --version

What does this command confirm?

The new output confirms which Node.js installation the reopened terminal can use. Version v24.22.0 LTS satisfies Vite's requirement.

Still seeing the older version?

Confirm that the Node.js installer completed before you reopened Visual Studio Code. A terminal left open during the installation can keep using the previous runtime.

Restart Windows if every reopened terminal still reports the older version. Help me diagnose why Visual Studio Code still detects my older Node.js installation.

ⓧ Command Not Found

The terminal cannot find a Node.js installation. Installing the current LTS release adds the runtime required by Vite.

The Windows installer may request permission to change your device. It also presents installation choices before copying the runtime.

  • Visit the official Node.js download page.
  • Download Node.js v24.22.0 LTS for Windows.
  • Run the downloaded Windows installer.
  • Keep the default installation choices.
  • Close Visual Studio Code after the installation completes.
  • Press the Windows key to open Windows search.
  • Type Visual Studio Code into the search field.
  • Press Enter to reopen Visual Studio Code.

The reopened editor can now load the runtime path added by the installer.

  • Click File in the top menu.
  • Select Open Folder.
  • Choose the incident-operations-dashboard folder on your Desktop.
  • Select View in the top menu.
  • Select Terminal.
  • Verify the Node.js installation by running this command:
node --version

What does this command confirm?

The output confirms that Windows can now find Node.js. Version v24.22.0 LTS satisfies Vite's requirement.

Command still unavailable?

Confirm that the installer completed successfully. Restart Windows if the reopened terminal still cannot locate Node.js.

Help me troubleshoot why Node.js is unavailable in the Visual Studio Code terminal.

Create the pinned Vite files

Manual project files keep every dependency pin visible. This avoids relying on a scaffold whose generated file tree can change over time.

Why Vite for this project?

Vite keeps this project focused on client-side state architecture. Its development server provides a short path from a saved component to a browser result.

A framework such as Next.js introduces rendering strategy plus server boundaries. Those concerns belong in a separate system design project.

  • Create package.json inside incident-operations-dashboard with the new-file control in the left file sidebar.
  • Add the pinned scripts plus dependencies by pasting this code:
{
  "name": "incident-operations-dashboard",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "react": "19.3.1",
    "react-dom": "19.3.1"
  },
  "devDependencies": {
    "@vitejs/plugin-react": "6.1.2",
    "vite": "8.3.4"
  }
}

What does this configuration do?

  • The scripts object exposes Vite's development server plus production build commands.
  • The dependencies object pins React plus react-dom to 19.3.1.
  • The devDependencies object pins Vite to 8.3.4. It also pins the React plugin to 6.1.2.
  • Save package.json.
  • Switch back to the integrated terminal.
  • Install the declared dependencies by running this command:
npm install

What does this command do?

The command reads package.json and installs its packages into a local node_modules folder. It also records the resolved dependency tree in package-lock.json.

The terminal finishes with an installation summary. The file sidebar now lists package-lock.json beside package.json.

Dependency installation failing?

Confirm that the terminal path ends with incident-operations-dashboard. Confirm that package.json is saved in that folder.

A network interruption can also stop package downloads. Help me troubleshoot this npm installation. If the problem continues, share the same terminal output in the NextWork community.

  • Create vite.config.js inside incident-operations-dashboard with the new-file control in the left file sidebar.
  • Configure Vite's React plugin by pasting this code:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
});

What does this configuration do?

  • The imports load Vite's configuration helper plus the React plugin.
  • The plugins array enables React support when Vite processes the project.
  • Save vite.config.js.
  • Confirm that vite.config.js appears beside package.json in the file sidebar.

Configuration file missing?

Check that vite.config.js sits directly inside incident-operations-dashboard. Check that its name ends with .js.

Help me verify my Vite configuration file location.

  • Create index.html inside incident-operations-dashboard with the new-file control in the left file sidebar.
  • Add the page entry point by pasting this markup:
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Incident Operations Dashboard</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>

What does this markup do?

  • The root element gives React a browser container.
  • The module script loads src/main.jsx as the application entry point.
  • The page title identifies the dashboard in the browser tab.
  • Save index.html.
  • Confirm that index.html appears beside vite.config.js in the file sidebar.

Entry file in the wrong place?

Keep index.html directly inside incident-operations-dashboard. Vite uses that root-level file as the application entry point.

Help me check my Vite HTML entry file.

Mount the starter React app

A React root connects the component tree to the browser's root element. The starter component gives that connection an immediate visual result.

  • Create a src folder inside incident-operations-dashboard with the new-folder control in the left file sidebar.
  • Confirm that the empty src folder appears in the file sidebar.
  • Create App.jsx inside the src folder with the new-file control.
  • Add the starter component by pasting this code:
export default function App() {
  return <h1>Incident Operations Dashboard</h1>;
}

What does this component do?

The App component returns the dashboard heading. Its default export lets the entry file import it directly.

  • Save src/App.jsx.
  • Confirm that App.jsx appears inside src in the file sidebar.

Component file not listed?

Check that App.jsx is inside the src folder. Check the uppercase A in its file name.

Help me verify the location of my starter App component.

  • Create main.jsx inside the src folder with the new-file control.
  • Mount the component by pasting this code:
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './styles.css';

createRoot(document.getElementById('root')).render(<App />);

What does this entry file do?

  • The imports load createRoot plus the starter component.
  • The stylesheet import includes the global page rules.
  • The final line renders App inside the root element from index.html.
  • Save src/main.jsx.
  • Confirm that main.jsx appears beside App.jsx in the file sidebar.

Entry module not listed?

Check that the file is named main.jsx. Check that it sits directly inside src.

Help me verify my React entry module.

  • Create styles.css inside the src folder with the new-file control.
  • Add the starter page styles by pasting this code:
body {
  margin: 0;
  font-family: Inter, ui-sans-serif, system-ui, sans-serif;
}

What do these styles do?

The body rule removes the browser's default outer margin. The font stack gives the starter page a system-friendly typeface.

  • Save src/styles.css.
  • Confirm that styles.css appears beside main.jsx in the file sidebar.

Stylesheet not listed?

Check that styles.css sits directly inside src. Check that its name matches the import in main.jsx.

Help me check my starter stylesheet path.

✔️ Awesome, I've got everything!

Great. Double-check that every project file is saved.

ⓧ I'd like to double check the full code

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Incident Operations Dashboard</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>
{
  "name": "incident-operations-dashboard",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "react": "19.3.1",
    "react-dom": "19.3.1"
  },
  "devDependencies": {
    "@vitejs/plugin-react": "6.1.2",
    "vite": "8.3.4"
  }
}
export default function App() {
  return <h1>Incident Operations Dashboard</h1>;
}
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './styles.css';

createRoot(document.getElementById('root')).render(<App />);
body {
  margin: 0;
  font-family: Inter, ui-sans-serif, system-ui, sans-serif;
}
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
});

The development server keeps the integrated terminal occupied while it runs. Keep q followed by Enter as the stop shortcut for later.

Before you start the server, which heading do you expect the browser to display?

  • Switch back to the integrated terminal.
  • Start the Vite development server by running this command:
npm run dev

What does this command do?

The command runs the dev script from package.json. Vite starts a local development server plus prints its Local URL.

  • Open the Local URL printed in the terminal.

Your browser displays the Incident Operations Dashboard heading. That visible screen proves the pinned React workspace is connected from HTML entry point to rendered component.

Starter heading not showing?

Confirm that the terminal is running from incident-operations-dashboard. Use the exact Local URL printed by Vite because another process may occupy the default port.

Check that index.html points to /src/main.jsx. Check that main.jsx imports ./App.jsx.

Help me troubleshoot why the starter dashboard does not render.

Your pinned workspace is running from the browser through the React entry point. Next, you will turn this starter heading into the first working incident dashboard.

Build the First Dashboard

Your pinned React workspace is running in the browser. Now it needs enough behavior to prove the dashboard idea.

This first dashboard keeps its shared state in App. It derives every visible view during render.

In this step, get ready to:
  • Create incident records with stable IDs.
  • Build filtering and selection into the dashboard.
  • Apply a responsive dashboard layout.
Create incident records

Each incident needs a stable identity across every dashboard view. Its ID lets selection survive filtering without copying the full incident object.

  • In the Visual Studio Code file sidebar, right-click the src folder.
  • Choose the option for creating a folder.
  • Enter data as the folder name.
  • Right-click the new data folder.
  • Choose the option for creating a file.
  • Enter incidents.js as the file name.
  • Add the incident collection to src/data/incidents.js by pasting this code:
export const initialIncidents = [
  {
    id: 'INC-1042',
    title: 'Checkout latency spike',
    service: 'payments-api',
    severity: 'critical',
    status: 'open',
    owner: 'Maya Chen',
    description: 'Checkout requests are exceeding the latency target in the primary region.',
  },
  {
    id: 'INC-1041',
    title: 'Delayed notification delivery',
    service: 'notification-worker',
    severity: 'high',
    status: 'acknowledged',
    owner: 'Noah Williams',
    description: 'Queued notifications are being delivered several minutes later than expected.',
  },
  {
    id: 'INC-1039',
    title: 'Search index replica unavailable',
    service: 'catalog-search',
    severity: 'high',
    status: 'open',
    owner: 'Priya Shah',
    description: 'One search replica is unavailable and traffic is concentrated on the remaining nodes.',
  },
  {
    id: 'INC-1037',
    title: 'Profile image upload failures',
    service: 'media-service',
    severity: 'medium',
    status: 'resolved',
    owner: 'Ethan Brooks',
    description: 'A storage policy change temporarily blocked profile image uploads.',
  },
];

What does this data do?

  • The initialIncidents export gives the dashboard one incident collection to read.
  • Each id provides a stable identity for selection.
  • The status value supports filtering and summary counts.
  • The remaining fields provide the list labels and details content.
  • Save src/data/incidents.js.
  • Confirm incidents.js appears inside the src/data folder in the file sidebar.

Can't find the incident file?

Confirm that incidents.js sits inside the data folder under src. A file outside that path will not match the dashboard import.

Compare the opening and closing boundaries of the array. Help me check my incident data file.

✔️ Awesome, I've got everything!

Great. Double-check that you saved src/data/incidents.js.

ⓧ I'd like to double check the full code

export const initialIncidents = [
  {
    id: 'INC-1042',
    title: 'Checkout latency spike',
    service: 'payments-api',
    severity: 'critical',
    status: 'open',
    owner: 'Maya Chen',
    description: 'Checkout requests are exceeding the latency target in the primary region.',
  },
  {
    id: 'INC-1041',
    title: 'Delayed notification delivery',
    service: 'notification-worker',
    severity: 'high',
    status: 'acknowledged',
    owner: 'Noah Williams',
    description: 'Queued notifications are being delivered several minutes later than expected.',
  },
  {
    id: 'INC-1039',
    title: 'Search index replica unavailable',
    service: 'catalog-search',
    severity: 'high',
    status: 'open',
    owner: 'Priya Shah',
    description: 'One search replica is unavailable and traffic is concentrated on the remaining nodes.',
  },
  {
    id: 'INC-1037',
    title: 'Profile image upload failures',
    service: 'media-service',
    severity: 'medium',
    status: 'resolved',
    owner: 'Ethan Brooks',
    description: 'A storage policy change temporarily blocked profile image uploads.',
  },
];
Build the dashboard behavior

The incident collection is the dashboard's single source of truth. Summary counts and visible rows can be calculated from that collection whenever React renders.

  • Select src/App.jsx in the Visual Studio Code file sidebar.
  • Replace the starter component in src/App.jsx by pasting this code:
import { useState } from 'react';
import { initialIncidents } from './data/incidents.js';

export default function App() {
  const [incidents] = useState(initialIncidents);
  const [selectedIncidentId, setSelectedIncidentId] = useState(initialIncidents[0].id);
  const [statusFilter, setStatusFilter] = useState('all');

  const visibleIncidents = incidents.filter(
    (incident) => statusFilter === 'all' || incident.status === statusFilter,
  );
  const incident = incidents.find(
    (candidate) => candidate.id === selectedIncidentId,
  );
  const counts = {
    all: incidents.length,
    open: incidents.filter((item) => item.status === 'open').length,
    acknowledged: incidents.filter((item) => item.status === 'acknowledged').length,
    resolved: incidents.filter((item) => item.status === 'resolved').length,
  };

  return (
    <main className="app-shell">
      <header className="page-header">
        <div>
          <p className="eyebrow">System design lab</p>
          <h1>Incident Operations Dashboard</h1>
          <p className="subtitle">One source of truth for every incident view.</p>
        </div>
      </header>
      <section className="summary-grid" aria-label="Incident summary">
        {Object.entries(counts).map(([key, count]) => <article className="summary-card" key={key}><span>{key}</span><strong>{count}</strong></article>)}
      </section>
      <section className="filter-bar"><label htmlFor="status-filter">Status</label><select id="status-filter" value={statusFilter} onChange={(event) => setStatusFilter(event.target.value)}><option value="all">All incidents</option><option value="open">Open</option><option value="acknowledged">Acknowledged</option><option value="resolved">Resolved</option></select></section>
      <section className="dashboard-grid"><section className="panel"><div className="panel-heading"><h2>Incident queue</h2><span>{visibleIncidents.length} visible</span></div><div className="incident-list">{visibleIncidents.map((item) => <button className={`incident-row ${selectedIncidentId === item.id ? 'selected' : ''}`} key={item.id} type="button" onClick={() => setSelectedIncidentId(item.id)}><span className={`severity-dot severity-${item.severity}`} /><span className="incident-copy"><strong>{item.title}</strong><small>{item.id} · {item.service}</small></span><span className={`status-pill status-${item.status}`}>{item.status}</span></button>)}</div></section><section className="panel details-panel">{incident && <><span className={`status-pill status-${incident.status}`}>{incident.status}</span><h2>{incident.title}</h2><p className="incident-id">{incident.id}</p><p>{incident.description}</p></>}</section></section>
    </main>
  );
}

What does this code do?

  • The three useState calls own the collection, selected ID, and active status filter.
  • The visibleIncidents calculation applies the selected filter during render.
  • The incident calculation finds the selected record from its stable ID.
  • The counts object derives every summary value from the incident collection.
  • The event handlers update only the selected ID or active filter.

Why start with local React state?

Keeping this first version in App makes state ownership visible. Every child view receives information from the same place.

A third-party store such as Redux would add another abstraction at this stage. This baseline makes each later architecture change answer a problem you can observe.

  • Save src/App.jsx.
  • Switch back to the browser tab running the dashboard.

The Vite development server refreshes the page. You should see four summary values above a status dropdown.

Good progress. Your incident rows now feed a details panel from one selected ID.

Dashboard page blank?

Confirm that the import path in src/App.jsx points to ./data/incidents.js. Check that the file exports initialIncidents with the same spelling.

Compare the closing braces around the component's returned markup. Help me find the dashboard rendering problem.

✔️ Awesome, I've got everything!

Great. Double-check that you saved src/App.jsx.

ⓧ I'd like to double check the full code

import { useState } from 'react';
import { initialIncidents } from './data/incidents.js';

export default function App() {
  const [incidents] = useState(initialIncidents);
  const [selectedIncidentId, setSelectedIncidentId] = useState(initialIncidents[0].id);
  const [statusFilter, setStatusFilter] = useState('all');

  const visibleIncidents = incidents.filter(
    (incident) => statusFilter === 'all' || incident.status === statusFilter,
  );
  const incident = incidents.find(
    (candidate) => candidate.id === selectedIncidentId,
  );
  const counts = {
    all: incidents.length,
    open: incidents.filter((item) => item.status === 'open').length,
    acknowledged: incidents.filter((item) => item.status === 'acknowledged').length,
    resolved: incidents.filter((item) => item.status === 'resolved').length,
  };

  return (
    <main className="app-shell">
      <header className="page-header">
        <div>
          <p className="eyebrow">System design lab</p>
          <h1>Incident Operations Dashboard</h1>
          <p className="subtitle">One source of truth for every incident view.</p>
        </div>
      </header>
      <section className="summary-grid" aria-label="Incident summary">
        {Object.entries(counts).map(([key, count]) => <article className="summary-card" key={key}><span>{key}</span><strong>{count}</strong></article>)}
      </section>
      <section className="filter-bar"><label htmlFor="status-filter">Status</label><select id="status-filter" value={statusFilter} onChange={(event) => setStatusFilter(event.target.value)}><option value="all">All incidents</option><option value="open">Open</option><option value="acknowledged">Acknowledged</option><option value="resolved">Resolved</option></select></section>
      <section className="dashboard-grid"><section className="panel"><div className="panel-heading"><h2>Incident queue</h2><span>{visibleIncidents.length} visible</span></div><div className="incident-list">{visibleIncidents.map((item) => <button className={`incident-row ${selectedIncidentId === item.id ? 'selected' : ''}`} key={item.id} type="button" onClick={() => setSelectedIncidentId(item.id)}><span className={`severity-dot severity-${item.severity}`} /><span className="incident-copy"><strong>{item.title}</strong><small>{item.id} · {item.service}</small></span><span className={`status-pill status-${item.status}`}>{item.status}</span></button>)}</div></section><section className="panel details-panel">{incident && <><span className={`status-pill status-${incident.status}`}>{incident.status}</span><h2>{incident.title}</h2><p className="incident-id">{incident.id}</p><p>{incident.description}</p></>}</section></section>
    </main>
  );
}
Style and test the dashboard

The final layer turns the working controls into a responsive on-call console. A narrow-width rule keeps the summary and incident details readable on smaller screens.

  • Select src/styles.css in the Visual Studio Code file sidebar.
  • Replace the existing styles in src/styles.css by pasting this code:
:root { font-family: Inter, ui-sans-serif, system-ui, sans-serif; color: #e5edf8; background: #07111f; } * { box-sizing: border-box; } body { margin: 0; min-width: 320px; min-height: 100vh; background: #07111f; } button, select { font: inherit; } button { cursor: pointer; } .app-shell { width: min(1120px, calc(100% - 32px)); margin: 0 auto; padding: 48px 0; } .page-header { margin-bottom: 28px; } .eyebrow { color: #79b8ff; font-size: .75rem; font-weight: 800; text-transform: uppercase; } .subtitle, .incident-id, .panel-heading span, .incident-copy small { color: #91a4bc; } .summary-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 16px; } .summary-card, .panel { padding: 18px; color: #c7d5e8; background: #0d1b2d; border: 1px solid #243752; border-radius: 14px; } .summary-card { display: flex; justify-content: space-between; } .filter-bar { display: flex; justify-content: flex-end; gap: 10px; margin-bottom: 16px; } .dashboard-grid { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(300px, .75fr); gap: 16px; } .panel-heading, .incident-row { display: flex; justify-content: space-between; align-items: center; } .incident-list { display: grid; gap: 10px; } .incident-row { gap: 12px; padding: 14px; color: #dce7f5; text-align: left; background: #0a1727; border: 1px solid transparent; border-radius: 12px; } .incident-row.selected { border-color: #4b8fd8; } .incident-copy { display: grid; gap: 5px; } .severity-dot { width: 10px; height: 10px; border-radius: 999px; background: #ff5f6d; } .severity-high { color: #ffc66d; } .severity-medium { color: #7fd3ff; } .status-pill { padding: 5px 9px; border-radius: 999px; } @media (max-width: 780px) { .summary-grid, .dashboard-grid { grid-template-columns: 1fr; } }

What do these styles do?

  • The page rules establish the dark dashboard canvas.
  • The summary grid presents the four derived counts as cards.
  • The dashboard grid places the queue beside the details panel.
  • The selected row receives a visible border.
  • The media query stacks the grids below 780px.
  • Save src/styles.css.

Before you check the browser, which parts of the dashboard do you expect to respond when you change the status filter or select a row?

  • Switch back to the browser tab running the dashboard.
  • Select Open from the status dropdown.

You should see two incident rows in the queue. The queue heading should show 2 visible.

  • Select the Search index replica unavailable incident row.

You should see a border around that row. The details panel should show the same incident title and ID.

  • Resize the browser window to a width below 780px.

You should see the summary cards stack into one column. The details panel should move below the incident queue.

That first working slice is complete. Filtering changes the queue while selection drives the details panel.

Layout or interactions look wrong?

Confirm that src/main.jsx still imports ./styles.css. Check that the class names in src/App.jsx match the selectors in the stylesheet.

If filtering works but selection does not move, compare the row's click handler with setSelectedIncidentId. Help me debug this dashboard behavior.

✔️ Awesome, I've got everything!

Great. Double-check that you saved src/styles.css.

ⓧ I'd like to double check the full code

:root { font-family: Inter, ui-sans-serif, system-ui, sans-serif; color: #e5edf8; background: #07111f; } * { box-sizing: border-box; } body { margin: 0; min-width: 320px; min-height: 100vh; background: #07111f; } button, select { font: inherit; } button { cursor: pointer; } .app-shell { width: min(1120px, calc(100% - 32px)); margin: 0 auto; padding: 48px 0; } .page-header { margin-bottom: 28px; } .eyebrow { color: #79b8ff; font-size: .75rem; font-weight: 800; text-transform: uppercase; } .subtitle, .incident-id, .panel-heading span, .incident-copy small { color: #91a4bc; } .summary-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 16px; } .summary-card, .panel { padding: 18px; color: #c7d5e8; background: #0d1b2d; border: 1px solid #243752; border-radius: 14px; } .summary-card { display: flex; justify-content: space-between; } .filter-bar { display: flex; justify-content: flex-end; gap: 10px; margin-bottom: 16px; } .dashboard-grid { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(300px, .75fr); gap: 16px; } .panel-heading, .incident-row { display: flex; justify-content: space-between; align-items: center; } .incident-list { display: grid; gap: 10px; } .incident-row { gap: 12px; padding: 14px; color: #dce7f5; text-align: left; background: #0a1727; border: 1px solid transparent; border-radius: 12px; } .incident-row.selected { border-color: #4b8fd8; } .incident-copy { display: grid; gap: 5px; } .severity-dot { width: 10px; height: 10px; border-radius: 999px; background: #ff5f6d; } .severity-high { color: #ffc66d; } .severity-medium { color: #7fd3ff; } .status-pill { padding: 5px 9px; border-radius: 999px; } @media (max-width: 780px) { .summary-grid, .dashboard-grid { grid-template-columns: 1fr; } }

Your first dashboard now provides a working architecture baseline. Next, you will split it into focused components and test how state ownership behaves across those boundaries.

Make the State Bug Visible

Your first dashboard already keeps filtering and selection synchronized from one component. Every visible value comes from the same incident collection.

Splitting the screen into focused React components makes each responsibility easier to see. It also tests whether one business fact can accidentally gain a second owner.

In this step, get ready to:
  • Extract the summary and filter areas into focused components.
  • Extract the incident list and details panel while threading data through props.
  • Trigger an acknowledgment that exposes two competing status values.
Extract the summary and filter controls

The summary and filter controls have clear boundaries because each represents one dashboard responsibility. Props let them receive data from the state owner without moving ownership yet.

  • In the Explorer sidebar in Visual Studio Code, select the src folder.
  • Click the New Folder icon in the Explorer toolbar.
  • Enter components as the folder name.
  • Select the new components folder.
  • Click the New File icon in the Explorer toolbar.
  • Enter SummaryCards.jsx as the file name.
  • Add the derived summary component to src/components/SummaryCards.jsx by pasting this code:
export default function SummaryCards({ incidents }) {
  // Derive every card from the incident collection owned by App.
  const counts = {
    all: incidents.length,
    open: incidents.filter((incident) => incident.status === 'open').length,
    acknowledged: incidents.filter(
      (incident) => incident.status === 'acknowledged',
    ).length,
    resolved: incidents.filter((incident) => incident.status === 'resolved')
      .length,
  };

  return (
    <section className="summary-grid" aria-label="Incident summary">
      {Object.entries(counts).map(([key, count]) => (
        <article className="summary-card" key={key}>
          <span>{key}</span>
          <strong>{count}</strong>
        </article>
      ))}
    </section>
  );
}

What does this component do?

  • The incidents prop gives the component the current incident collection.
  • The counts object derives each total during render.
  • The component displays those totals without storing another copy in state.
  • Save src/components/SummaryCards.jsx.
  • Confirm SummaryCards.jsx appears inside the src/components folder in the Explorer sidebar.

Can't find the summary component?

Check that SummaryCards.jsx is inside src/components. A file beside src will have a different import path.

Make sure the file name uses the same capital letters. Help me check my SummaryCards component location.

✔️ Awesome, I've got everything!

Great. Confirm that you saved src/components/SummaryCards.jsx.

ⓧ I'd like to double check the full code

export default function SummaryCards({ incidents }) {
  // Derive every card from the incident collection owned by App.
  const counts = {
    all: incidents.length,
    open: incidents.filter((incident) => incident.status === 'open').length,
    acknowledged: incidents.filter(
      (incident) => incident.status === 'acknowledged',
    ).length,
    resolved: incidents.filter((incident) => incident.status === 'resolved')
      .length,
  };

  return (
    <section className="summary-grid" aria-label="Incident summary">
      {Object.entries(counts).map(([key, count]) => (
        <article className="summary-card" key={key}>
          <span>{key}</span>
          <strong>{count}</strong>
        </article>
      ))}
    </section>
  );
}

The filter component reports a selection back to App. This keeps statusFilter under the same owner as the incident collection.

  • Select the src/components folder in the Explorer sidebar.
  • Click the New File icon in the Explorer toolbar.
  • Enter FilterBar.jsx as the file name.
  • Add the controlled status dropdown to src/components/FilterBar.jsx by pasting this code:
export default function FilterBar({
  statusFilter,
  onStatusFilterChange,
}) {
  // Report filter intent to the state owner in App.
  return (
    <section className="filter-bar">
      <label htmlFor="status-filter">Status</label>
      <select
        id="status-filter"
        value={statusFilter}
        onChange={(event) => onStatusFilterChange(event.target.value)}
      >
        <option value="all">All incidents</option>
        <option value="open">Open</option>
        <option value="acknowledged">Acknowledged</option>
        <option value="resolved">Resolved</option>
      </select>
    </section>
  );
}

What does this component do?

  • The statusFilter prop controls the selected dropdown value.
  • The onStatusFilterChange callback reports the next filter to App.
  • The component handles filter input without claiming ownership of the filter state.
  • Save src/components/FilterBar.jsx.
  • Confirm FilterBar.jsx appears beside SummaryCards.jsx in the Explorer sidebar.

Seeing a syntax warning?

Check the closing parenthesis after onStatusFilterChange(event.target.value). Confirm that every select tag has a matching closing tag.

Compare the prop names with the function parameters. Help me find the syntax problem in FilterBar.jsx.

✔️ Awesome, I've got everything!

Good. Confirm that both extracted control components are saved.

ⓧ I'd like to double check the full code

export default function FilterBar({
  statusFilter,
  onStatusFilterChange,
}) {
  // Report filter intent to the state owner in App.
  return (
    <section className="filter-bar">
      <label htmlFor="status-filter">Status</label>
      <select
        id="status-filter"
        value={statusFilter}
        onChange={(event) => onStatusFilterChange(event.target.value)}
      >
        <option value="all">All incidents</option>
        <option value="open">Open</option>
        <option value="acknowledged">Acknowledged</option>
        <option value="resolved">Resolved</option>
      </select>
    </section>
  );
}
Separate the incident list and details panel

The incident list receives an already filtered array from App. It reports selection intent through a callback because selectedIncidentId still belongs to the parent.

  • Select the src/components folder in the Explorer sidebar.
  • Click the New File icon in the Explorer toolbar.
  • Enter IncidentList.jsx as the file name.
  • Add the selectable incident queue to src/components/IncidentList.jsx by pasting this code:
export default function IncidentList({ visibleIncidents, selectedIncidentId, onSelectIncident }) {
  // Render the filtered incidents without owning selection state.
  return (
    <section className="panel">
      <div className="panel-heading">
        <h2>Incident queue</h2>
        <span>{visibleIncidents.length} visible</span>
      </div>

      <div className="incident-list">
        {visibleIncidents.map((incident) => (
          <button
            className={`incident-row ${selectedIncidentId === incident.id ? 'selected' : ''}`}
            key={incident.id}
            type="button"
            onClick={() => onSelectIncident(incident.id)}
          >
            <span className={`severity-dot severity-${incident.severity}`} />
            <span className="incident-copy">
              <strong>{incident.title}</strong>
              <small>{incident.id} · {incident.service}</small>
            </span>
            <span className={`status-pill status-${incident.status}`}>
              {incident.status}
            </span>
          </button>
        ))}
      </div>
    </section>
  );
}

What does this component do?

  • The visibleIncidents prop contains the rows that match the active status filter.
  • The selectedIncidentId prop identifies the row that receives the selected style.
  • The onSelectIncident callback sends the clicked stable ID back to App.
  • Save src/components/IncidentList.jsx.
  • Confirm IncidentList.jsx appears in the src/components folder.

Incident list file showing errors?

Check that the template literal around incident-row uses matching backticks. Confirm that the mapped button closes before the mapping expression.

Keep the prop names identical to the function parameters. Help me debug IncidentList.jsx.

✔️ Awesome, I've got everything!

Great. Your incident queue now has its own component file.

ⓧ I'd like to double check the full code

export default function IncidentList({ visibleIncidents, selectedIncidentId, onSelectIncident }) {
  // Render the filtered incidents without owning selection state.
  return (
    <section className="panel">
      <div className="panel-heading">
        <h2>Incident queue</h2>
        <span>{visibleIncidents.length} visible</span>
      </div>

      <div className="incident-list">
        {visibleIncidents.map((incident) => (
          <button
            className={`incident-row ${selectedIncidentId === incident.id ? 'selected' : ''}`}
            key={incident.id}
            type="button"
            onClick={() => onSelectIncident(incident.id)}
          >
            <span className={`severity-dot severity-${incident.severity}`} />
            <span className="incident-copy">
              <strong>{incident.title}</strong>
              <small>{incident.id} · {incident.service}</small>
            </span>
            <span className={`status-pill status-${incident.status}`}>
              {incident.status}
            </span>
          </button>
        ))}
      </div>
    </section>
  );
}

The details panel needs a status value to power its new action. Giving that value local component state creates an authority boundary that you can test against the incident collection.

  • Select the src/components folder in the Explorer sidebar.
  • Click the New File icon in the Explorer toolbar.
  • Enter IncidentDetails.jsx as the file name.
  • Add the stateful details panel to src/components/IncidentDetails.jsx by pasting this code:
import { useState } from 'react';

export default function IncidentDetails({ incident }) {
  // Mirror the selected incident status inside the details panel.
  const [localStatus, setLocalStatus] = useState(incident.status);

  return (
    <section className="panel details-panel">
      <span className={`status-pill status-${localStatus}`}>
        {localStatus}
      </span>
      <h2>{incident.title}</h2>
      <p className="incident-id">{incident.id}</p>
      <p>{incident.description}</p>

      <div className="action-row">
        {localStatus === 'open' && (
          <button
            type="button"
            onClick={() => setLocalStatus('acknowledged')}
          >
            Acknowledge
          </button>
        )}
      </div>
    </section>
  );
}

What does this component do?

  • The incident prop supplies the selected incident's title and description.
  • The localStatus state starts with the selected incident's current status.
  • The Acknowledge button updates localStatus inside this component.
  • Save src/components/IncidentDetails.jsx.
  • Confirm IncidentDetails.jsx appears beside the other three component files.

Details component showing an import error?

Check that useState uses the same capital letters in the import and state declaration. Confirm that react remains lowercase in the import path.

Make sure the component receives an incident prop before reading its status. Help me debug IncidentDetails.jsx.

✔️ Awesome, I've got everything!

Good. All four dashboard areas now have focused component files.

ⓧ I'd like to double check the full code

import { useState } from 'react';

export default function IncidentDetails({ incident }) {
  // Mirror the selected incident status inside the details panel.
  const [localStatus, setLocalStatus] = useState(incident.status);

  return (
    <section className="panel details-panel">
      <span className={`status-pill status-${localStatus}`}>
        {localStatus}
      </span>
      <h2>{incident.title}</h2>
      <p className="incident-id">{incident.id}</p>
      <p>{incident.description}</p>

      <div className="action-row">
        {localStatus === 'open' && (
          <button
            type="button"
            onClick={() => setLocalStatus('acknowledged')}
          >
            Acknowledge
          </button>
        )}
      </div>
    </section>
  );
}
Connect the components and expose the split

The component files become active when App imports them and supplies their props. App continues to own the collection, selection, and filter.

  • Select src/App.jsx in the Explorer sidebar.
  • Replace the current contents of src/App.jsx with this component wiring:
import { useState } from 'react';
import FilterBar from './components/FilterBar.jsx'; import IncidentDetails from './components/IncidentDetails.jsx';
import IncidentList from './components/IncidentList.jsx'; import SummaryCards from './components/SummaryCards.jsx';
import { initialIncidents } from './data/incidents.js';

export default function App() {
  const [incidents] = useState(initialIncidents);
  const [selectedIncidentId, setSelectedIncidentId] = useState(initialIncidents[0].id);
  const [statusFilter, setStatusFilter] = useState('all');

  // Derive visible records and selection from the state owned here.
  const visibleIncidents = incidents.filter(
    (incident) => statusFilter === 'all' || incident.status === statusFilter,
  );
  const incident = incidents.find(
    (candidate) => candidate.id === selectedIncidentId,
  );

  return (
    <main className="app-shell">
      <header className="page-header"><div><p className="eyebrow">System design lab</p><h1>Incident Operations Dashboard</h1><p className="subtitle">One source of truth for every incident view.</p></div></header>
      <SummaryCards incidents={incidents} />
      <FilterBar statusFilter={statusFilter} onStatusFilterChange={setStatusFilter} />
      <section className="dashboard-grid">
        <IncidentList visibleIncidents={visibleIncidents} selectedIncidentId={selectedIncidentId} onSelectIncident={setSelectedIncidentId} />
        {incident && <IncidentDetails key={incident.id} incident={incident} />}
      </section>
    </main>
  );
}

What does this wiring do?

  • The imports replace the four inline dashboard areas with their extracted components.
  • The parent passes incidents to the summary component so its counts remain derived from the collection.
  • The parent passes state values down through props and receives user intent through callbacks.
  • The key remounts the details component when the selected incident ID changes.
  • Save src/App.jsx.
  • Return to the browser page served by the development server from earlier.

You should see the same summary cards, status dropdown, incident queue, and selected incident details. Open incidents now include an Acknowledge button in the details panel.

Dashboard stopped rendering?

Confirm that every imported file sits inside src/components. Check that each file name matches the import's capital letters.

Use the browser error overlay to identify the first component with a syntax problem. Help me trace this component extraction error.

✔️ Awesome, I've got everything!

The extracted dashboard is rendering. Double-check that you saved src/App.jsx.

ⓧ I'd like to double check the full code

import { useState } from 'react';
import FilterBar from './components/FilterBar.jsx'; import IncidentDetails from './components/IncidentDetails.jsx';
import IncidentList from './components/IncidentList.jsx'; import SummaryCards from './components/SummaryCards.jsx';
import { initialIncidents } from './data/incidents.js';

export default function App() {
  const [incidents] = useState(initialIncidents);
  const [selectedIncidentId, setSelectedIncidentId] = useState(initialIncidents[0].id);
  const [statusFilter, setStatusFilter] = useState('all');

  // Derive visible records and selection from the state owned here.
  const visibleIncidents = incidents.filter(
    (incident) => statusFilter === 'all' || incident.status === statusFilter,
  );
  const incident = incidents.find(
    (candidate) => candidate.id === selectedIncidentId,
  );

  return (
    <main className="app-shell">
      <header className="page-header"><div><p className="eyebrow">System design lab</p><h1>Incident Operations Dashboard</h1><p className="subtitle">One source of truth for every incident view.</p></div></header>
      <SummaryCards incidents={incidents} />
      <FilterBar statusFilter={statusFilter} onStatusFilterChange={setStatusFilter} />
      <section className="dashboard-grid">
        <IncidentList visibleIncidents={visibleIncidents} selectedIncidentId={selectedIncidentId} onSelectIncident={setSelectedIncidentId} />
        {incident && <IncidentDetails key={incident.id} incident={incident} />}
      </section>
    </main>
  );
}

Before you test the new action, do you expect every status display to update from one click?

  • Select All incidents from the Status dropdown.
  • Select the Checkout latency spike incident row.
  • Click Acknowledge in the details panel.

The details badge changes to acknowledged. The matching list badge still shows open.

The summary still reports two open incidents and one acknowledged incident. You have made the duplicated-state failure visible.

Where did ownership split?

The incidents collection says the selected incident is open. The details component's localStatus says the same incident is acknowledged.

Both values claim authority over one business fact. A single source of truth gives that fact one owner so every view derives the same answer.

You have reproduced the architectural failure instead of only reading about it. Next, you'll give every incident transition to one reducer so the dashboard moves as a single system.

Restore One Source of Truth

You exposed the duplicated-state bug by making one incident show two different statuses. That disagreement proved the dashboard has two owners for the same business fact.

A React reducer gives every incident transition one path through the application. The incident collection becomes the single source of truth for the details panel, list, visible rows, and summary counts.

In this step, get ready to:
  • Model selection, filtering, and status changes as reducer actions.
  • Remove the mirrored status from the details panel.
  • Prove every incident view updates from the same collection.
Model incident intent in a reducer

Each reducer action describes what the person intended to do. The reducer calculates the next state while preserving a valid selection for the active filter.

Why use a reducer here?

The dashboard already has related transitions across incidents, selection, and filtering. React's built-in reducer keeps those rules together before a third-party state library adds another abstraction.

Action names such as incident_status_changed describe intent. Components can dispatch that intent without implementing the workflow themselves.

  • In the Visual Studio Code Explorer sidebar from earlier, select the src folder.
  • Click the New Folder icon in the Explorer toolbar.
  • Enter state as the folder name.
  • Select the new state folder.
  • Click the New File icon in the Explorer toolbar.
  • Enter incidentReducer.js as the file name.
  • Create the reducer shell with its selection action by pasting this code into src/state/incidentReducer.js:
export function incidentReducer(state, action) {
  switch (action.type) {
    case 'incident_selected': {
      return {
        ...state,
        selectedIncidentId: action.incidentId,
      };
    }
    default: {
      throw new Error(`Unknown action: ${action.type}`);
    }
  }
}

What does this reducer shell do?

  • The incident_selected action stores the stable incident ID chosen by the learner.
  • The spread syntax preserves state fields that this action does not change.
  • The default branch exposes an unknown action immediately instead of allowing a silent state error.
  • Save src/state/incidentReducer.js.
  • Confirm incidentReducer.js appears inside the src/state folder in the Explorer sidebar.
  • In incidentReducer.js, place the next case immediately before the default branch.
  • Add the status-filter transition by pasting this code:
    case 'status_filter_changed': {
      const visibleIncidents =
        action.status === 'all'
          ? state.incidents
          : state.incidents.filter(
              (incident) => incident.status === action.status,
            );

      const selectionIsVisible = visibleIncidents.some(
        (incident) => incident.id === state.selectedIncidentId,
      );

      return {
        ...state,
        statusFilter: action.status,
        selectedIncidentId: selectionIsVisible
          ? state.selectedIncidentId
          : (visibleIncidents[0]?.id ?? null),
      };
    }

How does filtering protect selection?

  • The reducer derives the incidents visible under the requested status.
  • The selectionIsVisible check preserves the current ID when that incident still matches.
  • The fallback selects the first visible incident ID. An empty result stores null so the details panel can show its empty state.
  • Place the final case immediately before the default branch in incidentReducer.js.
  • Add the incident-status transition by pasting this code:
    case 'incident_status_changed': {
      const incidents = state.incidents.map((incident) =>
        incident.id === action.incidentId
          ? { ...incident, status: action.status }
          : incident,
      );

      const visibleIncidents =
        state.statusFilter === 'all'
          ? incidents
          : incidents.filter(
              (incident) => incident.status === state.statusFilter,
            );

      const selectionIsVisible = visibleIncidents.some(
        (incident) => incident.id === state.selectedIncidentId,
      );

      return {
        ...state,
        incidents,
        selectedIncidentId: selectionIsVisible
          ? state.selectedIncidentId
          : (visibleIncidents[0]?.id ?? null),
      };
    }

How does a status change stay consistent?

  • The map() call creates an updated incident array without mutating the existing state.
  • Every view receives the same updated collection after the reducer returns.
  • The visibility check moves selection when the changed incident leaves the active filter.
  • Save src/state/incidentReducer.js.
  • Confirm the file contains the three action names incident_selected, status_filter_changed, and incident_status_changed.

Reducer showing a syntax error?

Check that every case block closes before the next one begins. Keep the default branch last inside the switch.

Confirm the file has one closing brace for the switch and one for incidentReducer(). Help me find the syntax error in my incident reducer.

Connect every view to reducer state

The reducer becomes useful when every component reads from its state. Counts and filtered arrays remain derived state because the incident collection already contains the facts needed to calculate them.

This is a coordinated refactor across the component boundary. Use the full-file references below so each prop name matches the new reducer contract.

  • Replace the contents of src/App.jsx with its matching reference in the second tab below.
  • Replace the contents of src/components/FilterBar.jsx with its matching reference.
  • Replace the contents of src/components/IncidentList.jsx with its matching reference.

Where does state live now?

The useReducer() call in App.jsx owns the incident collection, selected ID, and status filter. FilterBar and IncidentList dispatch intent without owning copies of those values.

  • Replace the contents of src/components/SummaryCards.jsx with its matching reference.
  • Replace the contents of src/components/IncidentDetails.jsx with its matching reference.
  • Save every changed file.

✔️ Awesome, I've got everything!

Great. Confirm that you saved the reducer, app, and four component files.

ⓧ I'd like to double check the full code

export function incidentReducer(state, action) {
  switch (action.type) {
    case 'incident_selected': {
      return {
        ...state,
        selectedIncidentId: action.incidentId,
      };
    }
    case 'status_filter_changed': {
      const visibleIncidents =
        action.status === 'all'
          ? state.incidents
          : state.incidents.filter(
              (incident) => incident.status === action.status,
            );

      const selectionIsVisible = visibleIncidents.some(
        (incident) => incident.id === state.selectedIncidentId,
      );

      return {
        ...state,
        statusFilter: action.status,
        selectedIncidentId: selectionIsVisible
          ? state.selectedIncidentId
          : (visibleIncidents[0]?.id ?? null),
      };
    }
    case 'incident_status_changed': {
      const incidents = state.incidents.map((incident) =>
        incident.id === action.incidentId
          ? { ...incident, status: action.status }
          : incident,
      );

      const visibleIncidents =
        state.statusFilter === 'all'
          ? incidents
          : incidents.filter(
              (incident) => incident.status === state.statusFilter,
            );

      const selectionIsVisible = visibleIncidents.some(
        (incident) => incident.id === state.selectedIncidentId,
      );

      return {
        ...state,
        incidents,
        selectedIncidentId: selectionIsVisible
          ? state.selectedIncidentId
          : (visibleIncidents[0]?.id ?? null),
      };
    }
    default: {
      throw new Error(`Unknown action: ${action.type}`);
    }
  }
}

What should this reducer contain?

The reducer contains one case for each user intent. Both transitions that affect visibility preserve the selected ID only while it remains in the derived result.

import { useReducer } from 'react';
import FilterBar from './components/FilterBar.jsx';
import IncidentDetails from './components/IncidentDetails.jsx';
import IncidentList from './components/IncidentList.jsx';
import SummaryCards from './components/SummaryCards.jsx';
import { initialIncidents } from './data/incidents.js';
import { incidentReducer } from './state/incidentReducer.js';

const initialState = {
  incidents: initialIncidents,
  selectedIncidentId: initialIncidents[0].id,
  statusFilter: 'all',
};

export default function App() {
  const [state, dispatch] = useReducer(incidentReducer, initialState);

  return (
    <main className="app-shell">
      <header className="page-header">
        <div>
          <p className="eyebrow">System design lab</p>
          <h1>Incident Operations Dashboard</h1>
          <p className="subtitle">
            One source of truth for every incident view.
          </p>
        </div>
      </header>

      <SummaryCards
        incidents={state.incidents}
        statusFilter={state.statusFilter}
        dispatch={dispatch}
      />
      <FilterBar statusFilter={state.statusFilter} dispatch={dispatch} />

      <section className="dashboard-grid">
        <IncidentList
          incidents={state.incidents}
          selectedIncidentId={state.selectedIncidentId}
          statusFilter={state.statusFilter}
          dispatch={dispatch}
        />
        <IncidentDetails
          incidents={state.incidents}
          selectedIncidentId={state.selectedIncidentId}
          dispatch={dispatch}
        />
      </section>
    </main>
  );
}

What should App own?

The initial state contains the incident collection, selected ID, and status filter. App passes reducer state and dispatch through explicit props at this stage of the architecture.

export default function FilterBar({ statusFilter, dispatch }) {
  return (
    <section className="filter-bar">
      <label htmlFor="status-filter">Status</label>
      <select
        id="status-filter"
        value={statusFilter}
        onChange={(event) =>
          dispatch({
            type: 'status_filter_changed',
            status: event.target.value,
          })
        }
      >
        <option value="all">All incidents</option>
        <option value="open">Open</option>
        <option value="acknowledged">Acknowledged</option>
        <option value="resolved">Resolved</option>
      </select>
    </section>
  );
}

What should the filter dispatch?

The status dropdown dispatches status_filter_changed with the selected value. The reducer decides whether the current incident remains visible.

export default function IncidentList({
  incidents,
  selectedIncidentId,
  statusFilter,
  dispatch,
}) {
  const visibleIncidents = incidents.filter(
    (incident) => statusFilter === 'all' || incident.status === statusFilter,
  );

  return (
    <section className="panel">
      <div className="panel-heading">
        <h2>Incident queue</h2>
        <span>{visibleIncidents.length} visible</span>
      </div>

      {visibleIncidents.length === 0 ? (
        <p className="empty-state">No incidents match this filter.</p>
      ) : (
        <div className="incident-list">
          {visibleIncidents.map((incident) => (
            <button
              className={`incident-row ${
                selectedIncidentId === incident.id ? 'selected' : ''
              }`}
              key={incident.id}
              type="button"
              onClick={() =>
                dispatch({
                  type: 'incident_selected',
                  incidentId: incident.id,
                })
              }
            >
              <span className={`severity-dot severity-${incident.severity}`} />
              <span className="incident-copy">
                <strong>{incident.title}</strong>
                <small>
                  {incident.id} · {incident.service}
                </small>
              </span>
              <span className={`status-pill status-${incident.status}`}>
                {incident.status}
              </span>
            </button>
          ))}
        </div>
      )}
    </section>
  );
}

What should the list derive?

The list calculates visibleIncidents from the collection and active filter during render. Clicking a row dispatches its stable ID through incident_selected.

const cardDefinitions = [
  { key: 'all', label: 'Total' },
  { key: 'open', label: 'Open' },
  { key: 'acknowledged', label: 'Acknowledged' },
  { key: 'resolved', label: 'Resolved' },
];

export default function SummaryCards({ incidents, statusFilter, dispatch }) {
  const counts = {
    all: incidents.length,
    open: incidents.filter((incident) => incident.status === 'open').length,
    acknowledged: incidents.filter(
      (incident) => incident.status === 'acknowledged',
    ).length,
    resolved: incidents.filter((incident) => incident.status === 'resolved')
      .length,
  };

  return (
    <section className="summary-grid" aria-label="Incident summary">
      {cardDefinitions.map((card) => (
        <button
          className={`summary-card ${statusFilter === card.key ? 'active' : ''}`}
          key={card.key}
          type="button"
          aria-pressed={statusFilter === card.key}
          onClick={() =>
            dispatch({ type: 'status_filter_changed', status: card.key })
          }
        >
          <span>{card.label}</span>
          <strong>{counts[card.key]}</strong>
        </button>
      ))}
    </section>
  );
}

What should the summary derive?

The summary calculates every count directly from incidents. Its cards dispatch the same filter action used by the dropdown.

export default function IncidentDetails({
  incidents,
  selectedIncidentId,
  dispatch,
}) {
  const incident = incidents.find(
    (candidate) => candidate.id === selectedIncidentId,
  );

  if (!incident) {
    return (
      <section className="panel details-panel">
        <p className="empty-state">Select a visible incident to inspect it.</p>
      </section>
    );
  }

  function changeStatus(status) {
    dispatch({
      type: 'incident_status_changed',
      incidentId: incident.id,
      status,
    });
  }

  return (
    <section className="panel details-panel">
      <div className="details-topline">
        <span className={`status-pill status-${incident.status}`}>
          {incident.status}
        </span>
        <span className={`severity-label severity-${incident.severity}`}>
          {incident.severity}
        </span>
      </div>

      <h2>{incident.title}</h2>
      <p className="incident-id">{incident.id}</p>
      <p>{incident.description}</p>

      <dl className="details-list">
        <div>
          <dt>Service</dt>
          <dd>{incident.service}</dd>
        </div>
        <div>
          <dt>Owner</dt>
          <dd>{incident.owner}</dd>
        </div>
      </dl>

      <div className="action-row">
        {incident.status === 'open' && (
          <button type="button" onClick={() => changeStatus('acknowledged')}>
            Acknowledge
          </button>
        )}
        {incident.status !== 'resolved' && (
          <button
            className="primary-action"
            type="button"
            onClick={() => changeStatus('resolved')}
          >
            Resolve
          </button>
        )}
      </div>
    </section>
  );
}

What replaces the mirrored status?

The details panel finds the selected incident inside the shared collection. Its action buttons dispatch status changes against that incident ID, so no local status can drift away from the list.

The browser refreshes after the coordinated save. You should see the dashboard heading, four summary cards, the status filter, the incident queue, and the selected incident details.

Dashboard not rendering after the refactor?

Confirm the reducer import in App.jsx points to ./state/incidentReducer.js. Check that the file name uses the same capitalization.

Compare every component's prop names with the matching JSX in App.jsx. Help me diagnose the render error after connecting my reducer to these dashboard components.

Verify synchronized incident transitions

An active filter creates the strongest test because a status change can remove the selected incident from the visible list. The reducer must update the collection and choose a valid visible ID in the same transition.

  • Switch back to the dashboard in your browser.
  • Choose Open from the Status dropdown.
  • Select Checkout latency spike from the incident queue.

Before you change its status, predict which row the details panel will select when this incident leaves the open filter.

  • Click Acknowledge in the details panel.

You should see Checkout latency spike leave the open list. The details panel selects the remaining open incident, the Open count becomes 1, and the Acknowledged count becomes 2.

  • Choose Acknowledged from the Status dropdown.
  • Select Checkout latency spike from the incident queue.

Before you resolve it, predict how the acknowledged list and summary counts will change.

  • Click Resolve in the details panel.

You should see Checkout latency spike leave the acknowledged list. The details panel selects the remaining acknowledged incident, the Acknowledged count returns to 1, and the Resolved count becomes 2.

  • Choose Resolved from the Status dropdown.
  • Select Checkout latency spike from the incident queue.

The row badge and details badge both show resolved. The summary shows two resolved incidents, so every representation now agrees.

Seeing different statuses again?

Confirm IncidentDetails.jsx no longer imports or calls useState. Its badge must read incident.status from the shared collection.

Check that both buttons call changeStatus() and that the function dispatches incident_status_changed. Help me find the remaining duplicated incident status.

You fixed the architectural bug at its source. Selection, filtering, status actions, and summary counts now move together through one reducer.

Next, you will remove the remaining prop plumbing with separate state and dispatch contexts.

Replace Prop Drilling with Context

Your reducer now keeps every incident view synchronized. The dashboard proves that one owner can coordinate each workflow transition.

However, App still sends state through every component boundary. This prop drilling hides what each component actually needs.

React Context gives distant components direct access to shared state. Separate state and dispatch contexts keep each subscription focused.

In this step, get ready to:
  • Move the reducer state into a shared context provider.
  • Replace threaded props with focused context hooks.
  • Verify the architecture with a production build and an end-to-end test.
Create the context state module

A provider owns the reducer instance at the top of the dashboard. Custom hooks give each component a small entry point to the shared state or dispatch function.

Why reducer and context?

The reducer already models every incident transition. Context distributes that existing model without adding another state system.

A third-party store such as Redux can support larger applications. React's built-in tools keep this dashboard focused on the architectural pattern that solves its current problem.

  • Right-click the existing src/state folder in the file sidebar.
  • Choose the action for creating a new file.
  • Name the file IncidentContext.jsx.
  • Add the context definitions and provider by pasting this first section:
import { createContext, useContext, useReducer } from 'react';
import { initialIncidents } from '../data/incidents.js';

const IncidentStateContext = createContext(null);
const IncidentDispatchContext = createContext(null);

const initialState = {
  incidents: initialIncidents,
  selectedIncidentId: initialIncidents[0].id,
  statusFilter: 'all',
};

export function IncidentProvider({ children }) {
  const [state, dispatch] = useReducer(incidentReducer, initialState);
  return <IncidentStateContext value={state}><IncidentDispatchContext value={dispatch}>{children}</IncidentDispatchContext></IncidentStateContext>;
}

What does this provider do?

  • The state context publishes the current reducer state.
  • The dispatch context publishes the reducer's dispatch function.
  • The IncidentProvider creates one reducer instance for every component inside it.
  • The nested providers let consumers subscribe to the value they require.
  • Save src/state/IncidentContext.jsx.
  • Confirm the file appears inside the existing src/state folder.

Context file in the wrong folder?

Confirm that IncidentContext.jsx sits inside src/state. The import for initialIncidents depends on that location.

Check the capital letters in the filename. Help me verify the location and imports for IncidentContext.jsx.

  • Add the state hook below IncidentProvider by pasting this code:
export function useIncidentState() {
  const context = useContext(IncidentStateContext);
  if (context === null) throw new Error('useIncidentState must be used inside IncidentProvider');
  return context;
}

export function useIncidentDispatch() {
  const context = useContext(IncidentDispatchContext);
  if (context === null) throw new Error('useIncidentDispatch must be used inside IncidentProvider');
  return context;
}

What do these hooks do?

  • The useIncidentState hook reads the nearest incident state value.
  • The useIncidentDispatch hook reads the nearest incident dispatch function.
  • Each null check reports when a consumer sits outside IncidentProvider.
  • Save src/state/IncidentContext.jsx.
  • Confirm that both exported hook names appear in the file.

Hook name showing an error?

Check that each hook uses its matching context. The state hook reads IncidentStateContext.

The dispatch hook reads IncidentDispatchContext. Help me compare these two context hooks.

  • Move the reducer into the same module by adding this final section below the hooks:
export function incidentReducer(state, action) {
  switch (action.type) {
    case 'incident_selected': return { ...state, selectedIncidentId: action.incidentId };
    case 'status_filter_changed': {
      const visibleIncidents = action.status === 'all' ? state.incidents : state.incidents.filter((incident) => incident.status === action.status);
      const selectionIsVisible = visibleIncidents.some((incident) => incident.id === state.selectedIncidentId);
      return { ...state, statusFilter: action.status, selectedIncidentId: selectionIsVisible ? state.selectedIncidentId : (visibleIncidents[0]?.id ?? null) };
    }
    case 'incident_status_changed': {
      const incidents = state.incidents.map((incident) => incident.id === action.incidentId ? { ...incident, status: action.status } : incident);
      const visibleIncidents = state.statusFilter === 'all' ? incidents : incidents.filter((incident) => incident.status === state.statusFilter);
      const selectionIsVisible = visibleIncidents.some((incident) => incident.id === state.selectedIncidentId);
      return { ...state, incidents, selectedIncidentId: selectionIsVisible ? state.selectedIncidentId : (visibleIncidents[0]?.id ?? null) };
    }
    default: throw new Error(`Unknown action: ${action.type}`);
  }
}

What does the reducer own?

  • The reducer keeps the three intent-based actions from the previous step.
  • The status filter action preserves the selected ID only while that incident remains visible.
  • The status change action updates the incident collection before recalculating the valid selection.
  • The provider now owns the complete incident workflow.
  • Save src/state/IncidentContext.jsx.
  • Confirm that incidentReducer appears after the two custom hooks.

Reducer section showing a syntax error?

Check the closing braces for each action case. Confirm that the final brace closes incidentReducer.

Make sure the reducer remains below the custom hooks. Help me locate the syntax error in IncidentContext.jsx.

✔️ Awesome, I've got everything!

Great. Double-check that you saved src/state/IncidentContext.jsx.

ⓧ I'd like to double check the full code

import { createContext, useContext, useReducer } from 'react';
import { initialIncidents } from '../data/incidents.js';

const IncidentStateContext = createContext(null);
const IncidentDispatchContext = createContext(null);

const initialState = {
  incidents: initialIncidents,
  selectedIncidentId: initialIncidents[0].id,
  statusFilter: 'all',
};

export function IncidentProvider({ children }) {
  const [state, dispatch] = useReducer(incidentReducer, initialState);
  return <IncidentStateContext value={state}><IncidentDispatchContext value={dispatch}>{children}</IncidentDispatchContext></IncidentStateContext>;
}

export function useIncidentState() {
  const context = useContext(IncidentStateContext);
  if (context === null) throw new Error('useIncidentState must be used inside IncidentProvider');
  return context;
}

export function useIncidentDispatch() {
  const context = useContext(IncidentDispatchContext);
  if (context === null) throw new Error('useIncidentDispatch must be used inside IncidentProvider');
  return context;
}

export function incidentReducer(state, action) {
  switch (action.type) {
    case 'incident_selected': return { ...state, selectedIncidentId: action.incidentId };
    case 'status_filter_changed': {
      const visibleIncidents = action.status === 'all' ? state.incidents : state.incidents.filter((incident) => incident.status === action.status);
      const selectionIsVisible = visibleIncidents.some((incident) => incident.id === state.selectedIncidentId);
      return { ...state, statusFilter: action.status, selectedIncidentId: selectionIsVisible ? state.selectedIncidentId : (visibleIncidents[0]?.id ?? null) };
    }
    case 'incident_status_changed': {
      const incidents = state.incidents.map((incident) => incident.id === action.incidentId ? { ...incident, status: action.status } : incident);
      const visibleIncidents = state.statusFilter === 'all' ? incidents : incidents.filter((incident) => incident.status === state.statusFilter);
      const selectionIsVisible = visibleIncidents.some((incident) => incident.id === state.selectedIncidentId);
      return { ...state, incidents, selectedIncidentId: selectionIsVisible ? state.selectedIncidentId : (visibleIncidents[0]?.id ?? null) };
    }
    default: throw new Error(`Unknown action: ${action.type}`);
  }
}
Connect every component to context

The provider and its consumers form one coordinated wiring change. Pause the development process while you replace these connected files.

  • Return to the integrated terminal from earlier.
  • Stop the running development process using the terminal's interrupt shortcut.
  • Select src/components/SummaryCards.jsx in the file sidebar.
  • Replace its contents with this context-powered component:
import { useIncidentDispatch, useIncidentState } from '../state/IncidentContext.jsx';

const cardDefinitions = [{ key: 'all', label: 'Total' }, { key: 'open', label: 'Open' }, { key: 'acknowledged', label: 'Acknowledged' }, { key: 'resolved', label: 'Resolved' }];

export default function SummaryCards() {
  const { incidents, statusFilter } = useIncidentState();
  const dispatch = useIncidentDispatch();
  const counts = { all: incidents.length, open: incidents.filter((incident) => incident.status === 'open').length, acknowledged: incidents.filter((incident) => incident.status === 'acknowledged').length, resolved: incidents.filter((incident) => incident.status === 'resolved').length };
  return <section className="summary-grid" aria-label="Incident summary">{cardDefinitions.map((card) => <button className={`summary-card ${statusFilter === card.key ? 'active' : ''}`} key={card.key} type="button" aria-pressed={statusFilter === card.key} onClick={() => dispatch({ type: 'status_filter_changed', status: card.key })}><span>{card.label}</span><strong>{counts[card.key]}</strong></button>)}</section>;
}

How do summary cards use context?

  • The component reads only incidents and statusFilter from shared state.
  • Each card dispatches status_filter_changed directly.
  • The summary counts remain derived from the incident collection.
  • Save src/components/SummaryCards.jsx.
  • Confirm that the component no longer declares props in its function signature.

Summary hook import underlined?

Check that the import points to ../state/IncidentContext.jsx. The component sits one folder below src.

Confirm that both hook names match their exports. Help me fix the SummaryCards context import.

  • Select src/components/FilterBar.jsx in the file sidebar.
  • Replace its contents with this focused context consumer:
import { useIncidentDispatch, useIncidentState } from '../state/IncidentContext.jsx';

export default function FilterBar() {
  const { statusFilter } = useIncidentState();
  const dispatch = useIncidentDispatch();
  return <section className="filter-bar"><label htmlFor="status-filter">Status</label><select id="status-filter" value={statusFilter} onChange={(event) => dispatch({ type: 'status_filter_changed', status: event.target.value })}><option value="all">All incidents</option><option value="open">Open</option><option value="acknowledged">Acknowledged</option><option value="resolved">Resolved</option></select></section>;
}

How does the filter use context?

  • The dropdown reads only statusFilter from shared state.
  • Its change handler dispatches the selected status as user intent.
  • The component no longer depends on callback props from App.
  • Save src/components/FilterBar.jsx.
  • Confirm that FilterBar has an empty function parameter list.

Filter value not connected?

Check that the dropdown's value uses statusFilter. Confirm that the action uses event.target.value.

Make sure the action type remains status_filter_changed. Help me trace the FilterBar action into the reducer.

  • Select src/components/IncidentList.jsx in the file sidebar.
  • Replace its contents with this context-powered incident list:
import { useIncidentDispatch, useIncidentState } from '../state/IncidentContext.jsx';

export default function IncidentList() {
  const { incidents, selectedIncidentId, statusFilter } = useIncidentState();
  const dispatch = useIncidentDispatch();
  const visibleIncidents = incidents.filter((incident) => statusFilter === 'all' || incident.status === statusFilter);
  return <section className="panel"><div className="panel-heading"><h2>Incident queue</h2><span>{visibleIncidents.length} visible</span></div>{visibleIncidents.length === 0 ? <p className="empty-state">No incidents match this filter.</p> : <div className="incident-list">{visibleIncidents.map((incident) => <button className={`incident-row ${selectedIncidentId === incident.id ? 'selected' : ''}`} key={incident.id} type="button" onClick={() => dispatch({ type: 'incident_selected', incidentId: incident.id })}><span className={`severity-dot severity-${incident.severity}`} /><span className="incident-copy"><strong>{incident.title}</strong><small>{incident.id} · {incident.service}</small></span><span className={`status-pill status-${incident.status}`}>{incident.status}</span></button>)}</div>}</section>;
}

How does the list use context?

  • The list reads the collection from shared state.
  • It derives visibleIncidents during render from statusFilter.
  • Each incident row dispatches incident_selected without sending a callback through App.
  • Save src/components/IncidentList.jsx.
  • Confirm that the component still derives visibleIncidents from the context state.

Incident selection action missing?

Check that each row dispatches incident_selected. Confirm that the action includes incidentId.

Make sure the selected class compares against selectedIncidentId. Help me inspect the IncidentList selection flow.

  • Select src/components/IncidentDetails.jsx in the file sidebar.
  • Replace its contents with this context-powered details panel:
import { useIncidentDispatch, useIncidentState } from '../state/IncidentContext.jsx';

export default function IncidentDetails() {
  const { incidents, selectedIncidentId } = useIncidentState();
  const dispatch = useIncidentDispatch();
  const incident = incidents.find((candidate) => candidate.id === selectedIncidentId);
  if (!incident) return <section className="panel details-panel"><p className="empty-state">Select a visible incident to inspect it.</p></section>;
  function changeStatus(status) { dispatch({ type: 'incident_status_changed', incidentId: incident.id, status }); }
  return <section className="panel details-panel"><div className="details-topline"><span className={`status-pill status-${incident.status}`}>{incident.status}</span><span className={`severity-label severity-${incident.severity}`}>{incident.severity}</span></div><h2>{incident.title}</h2><p className="incident-id">{incident.id}</p><p>{incident.description}</p><dl className="details-list"><div><dt>Service</dt><dd>{incident.service}</dd></div><div><dt>Owner</dt><dd>{incident.owner}</dd></div></dl><div className="action-row">{incident.status === 'open' && <button type="button" onClick={() => changeStatus('acknowledged')}>Acknowledge</button>}{incident.status !== 'resolved' && <button className="primary-action" type="button" onClick={() => changeStatus('resolved')}>Resolve</button>}</div></section>;
}

How do details use context?

  • The component finds the selected incident from the shared collection.
  • The changeStatus helper dispatches incident_status_changed against that incident's stable ID.
  • The empty state handles filters that leave no valid selected incident.
  • Save src/components/IncidentDetails.jsx.
  • Confirm that the details component receives no props.

Details component cannot find an incident?

Check that useIncidentState returns both incidents and selectedIncidentId. Confirm that find compares the two IDs.

Keep the empty-state return before changeStatus uses the selected incident. Help me debug the selected incident lookup.

  • Select src/App.jsx in the file sidebar.
  • Replace its contents with this provider-based component tree:
import FilterBar from './components/FilterBar.jsx';
import IncidentDetails from './components/IncidentDetails.jsx';
import IncidentList from './components/IncidentList.jsx';
import SummaryCards from './components/SummaryCards.jsx';
import { IncidentProvider } from './state/IncidentContext.jsx';

function Dashboard() {
  return <main className="app-shell"><header className="page-header"><div><p className="eyebrow">System design lab</p><h1>Incident Operations Dashboard</h1><p className="subtitle">One source of truth for every incident view.</p></div></header><SummaryCards /><FilterBar /><section className="dashboard-grid"><IncidentList /><IncidentDetails /></section></main>;
}

export default function App() {
  return <IncidentProvider><Dashboard /></IncidentProvider>;
}

How did the component tree change?

  • The Dashboard component renders each consumer without state props.
  • The App component places IncidentProvider above the complete dashboard.
  • Every consumer now reads its required values from the nearest provider.
  • Save src/App.jsx.
  • Use the file sidebar's delete action on src/state/incidentReducer.js.
  • Confirm the deletion if Visual Studio Code asks for confirmation.

Provider error after the refactor?

Confirm that IncidentProvider wraps Dashboard. Every custom hook requires that provider above it.

Check that App.jsx imports the provider from ./state/IncidentContext.jsx. Help me diagnose the provider wiring.

✔️ Awesome, I've got everything!

Your provider and consumers now use the same context module. Double-check that every changed file is saved.

ⓧ I'd like to double check the full code

import { createContext, useContext, useReducer } from 'react';
import { initialIncidents } from '../data/incidents.js';

const IncidentStateContext = createContext(null);
const IncidentDispatchContext = createContext(null);

const initialState = {
  incidents: initialIncidents,
  selectedIncidentId: initialIncidents[0].id,
  statusFilter: 'all',
};

export function IncidentProvider({ children }) {
  const [state, dispatch] = useReducer(incidentReducer, initialState);
  return <IncidentStateContext value={state}><IncidentDispatchContext value={dispatch}>{children}</IncidentDispatchContext></IncidentStateContext>;
}

export function useIncidentState() {
  const context = useContext(IncidentStateContext);
  if (context === null) throw new Error('useIncidentState must be used inside IncidentProvider');
  return context;
}

export function useIncidentDispatch() {
  const context = useContext(IncidentDispatchContext);
  if (context === null) throw new Error('useIncidentDispatch must be used inside IncidentProvider');
  return context;
}

export function incidentReducer(state, action) {
  switch (action.type) {
    case 'incident_selected': return { ...state, selectedIncidentId: action.incidentId };
    case 'status_filter_changed': {
      const visibleIncidents = action.status === 'all' ? state.incidents : state.incidents.filter((incident) => incident.status === action.status);
      const selectionIsVisible = visibleIncidents.some((incident) => incident.id === state.selectedIncidentId);
      return { ...state, statusFilter: action.status, selectedIncidentId: selectionIsVisible ? state.selectedIncidentId : (visibleIncidents[0]?.id ?? null) };
    }
    case 'incident_status_changed': {
      const incidents = state.incidents.map((incident) => incident.id === action.incidentId ? { ...incident, status: action.status } : incident);
      const visibleIncidents = state.statusFilter === 'all' ? incidents : incidents.filter((incident) => incident.status === state.statusFilter);
      const selectionIsVisible = visibleIncidents.some((incident) => incident.id === state.selectedIncidentId);
      return { ...state, incidents, selectedIncidentId: selectionIsVisible ? state.selectedIncidentId : (visibleIncidents[0]?.id ?? null) };
    }
    default: throw new Error(`Unknown action: ${action.type}`);
  }
}
import { useIncidentDispatch, useIncidentState } from '../state/IncidentContext.jsx';

const cardDefinitions = [{ key: 'all', label: 'Total' }, { key: 'open', label: 'Open' }, { key: 'acknowledged', label: 'Acknowledged' }, { key: 'resolved', label: 'Resolved' }];

export default function SummaryCards() {
  const { incidents, statusFilter } = useIncidentState();
  const dispatch = useIncidentDispatch();
  const counts = { all: incidents.length, open: incidents.filter((incident) => incident.status === 'open').length, acknowledged: incidents.filter((incident) => incident.status === 'acknowledged').length, resolved: incidents.filter((incident) => incident.status === 'resolved').length };
  return <section className="summary-grid" aria-label="Incident summary">{cardDefinitions.map((card) => <button className={`summary-card ${statusFilter === card.key ? 'active' : ''}`} key={card.key} type="button" aria-pressed={statusFilter === card.key} onClick={() => dispatch({ type: 'status_filter_changed', status: card.key })}><span>{card.label}</span><strong>{counts[card.key]}</strong></button>)}</section>;
}
import { useIncidentDispatch, useIncidentState } from '../state/IncidentContext.jsx';

export default function FilterBar() {
  const { statusFilter } = useIncidentState();
  const dispatch = useIncidentDispatch();
  return <section className="filter-bar"><label htmlFor="status-filter">Status</label><select id="status-filter" value={statusFilter} onChange={(event) => dispatch({ type: 'status_filter_changed', status: event.target.value })}><option value="all">All incidents</option><option value="open">Open</option><option value="acknowledged">Acknowledged</option><option value="resolved">Resolved</option></select></section>;
}
import { useIncidentDispatch, useIncidentState } from '../state/IncidentContext.jsx';

export default function IncidentList() {
  const { incidents, selectedIncidentId, statusFilter } = useIncidentState();
  const dispatch = useIncidentDispatch();
  const visibleIncidents = incidents.filter((incident) => statusFilter === 'all' || incident.status === statusFilter);
  return <section className="panel"><div className="panel-heading"><h2>Incident queue</h2><span>{visibleIncidents.length} visible</span></div>{visibleIncidents.length === 0 ? <p className="empty-state">No incidents match this filter.</p> : <div className="incident-list">{visibleIncidents.map((incident) => <button className={`incident-row ${selectedIncidentId === incident.id ? 'selected' : ''}`} key={incident.id} type="button" onClick={() => dispatch({ type: 'incident_selected', incidentId: incident.id })}><span className={`severity-dot severity-${incident.severity}`} /><span className="incident-copy"><strong>{incident.title}</strong><small>{incident.id} · {incident.service}</small></span><span className={`status-pill status-${incident.status}`}>{incident.status}</span></button>)}</div>}</section>;
}
import { useIncidentDispatch, useIncidentState } from '../state/IncidentContext.jsx';

export default function IncidentDetails() {
  const { incidents, selectedIncidentId } = useIncidentState();
  const dispatch = useIncidentDispatch();
  const incident = incidents.find((candidate) => candidate.id === selectedIncidentId);
  if (!incident) return <section className="panel details-panel"><p className="empty-state">Select a visible incident to inspect it.</p></section>;
  function changeStatus(status) { dispatch({ type: 'incident_status_changed', incidentId: incident.id, status }); }
  return <section className="panel details-panel"><div className="details-topline"><span className={`status-pill status-${incident.status}`}>{incident.status}</span><span className={`severity-label severity-${incident.severity}`}>{incident.severity}</span></div><h2>{incident.title}</h2><p className="incident-id">{incident.id}</p><p>{incident.description}</p><dl className="details-list"><div><dt>Service</dt><dd>{incident.service}</dd></div><div><dt>Owner</dt><dd>{incident.owner}</dd></div></dl><div className="action-row">{incident.status === 'open' && <button type="button" onClick={() => changeStatus('acknowledged')}>Acknowledge</button>}{incident.status !== 'resolved' && <button className="primary-action" type="button" onClick={() => changeStatus('resolved')}>Resolve</button>}</div></section>;
}
import FilterBar from './components/FilterBar.jsx';
import IncidentDetails from './components/IncidentDetails.jsx';
import IncidentList from './components/IncidentList.jsx';
import SummaryCards from './components/SummaryCards.jsx';
import { IncidentProvider } from './state/IncidentContext.jsx';

function Dashboard() {
  return <main className="app-shell"><header className="page-header"><div><p className="eyebrow">System design lab</p><h1>Incident Operations Dashboard</h1><p className="subtitle">One source of truth for every incident view.</p></div></header><SummaryCards /><FilterBar /><section className="dashboard-grid"><IncidentList /><IncidentDetails /></section></main>;
}

export default function App() {
  return <IncidentProvider><Dashboard /></IncidentProvider>;
}

Before you restart the app, predict whether the dashboard can render without any state or callback props in App.

  • Restart the development server from the integrated terminal by running this command:
npm run dev
  • Open the Local URL printed by Vite.

You'll see the complete Incident Operations Dashboard. The summary cards and incident panels render without threaded props.

Dashboard showing a context error?

Confirm that every component imports its hooks from ../state/IncidentContext.jsx. Check that App wraps Dashboard with IncidentProvider.

Look for an old import from incidentReducer.js in App.jsx. Help me find the remaining prop or import mismatch.

Build and test the final flow

A production build asks Vite to resolve the complete module graph. This catches missing exports or stale imports before the architecture is considered finished.

  • Return to the integrated terminal that is running the development server.
  • Stop the development process using the terminal's interrupt shortcut.

Before you build, predict whether every context import and provider boundary now resolves successfully.

  • Create the production bundle by running this command:
npm run build

You'll see Vite finish the production bundle. You'll also see a new dist folder in the file sidebar.

Production build failing?

Read the first file path reported by the build. Check that its context import matches the location of src/state/IncidentContext.jsx.

Search src/App.jsx for the deleted incidentReducer.js import. Help me diagnose this production build failure.

Before the final run, predict whether a summary card can filter the distant incident list without a callback from App.

  • Start the final development server by running this command:
npm run dev
  • Open the Local URL printed by Vite.
  • Click the Total summary card.
  • Select the Checkout latency spike incident row.
  • Click Acknowledge in the details panel.

You'll see the details badge and incident row change to acknowledged. The Open count decreases while the Acknowledged count increases.

  • Click Resolve in the details panel.
  • Select Open from the status dropdown.

You'll see only the remaining open incident. The details panel follows that visible selection.

  • Click the Acknowledged summary card.

You'll see the incident list filter immediately. The summary card dispatched directly through context without state or callbacks passing through App.

A view stopped updating?

Confirm that every component calls useIncidentState for the values it renders. Check that every interaction calls useIncidentDispatch.

Make sure the summary card sends card.key as the action status. Help me trace the stale view back to its context consumer.

That's the architecture complete. Your dashboard now shares one reducer without hiding component responsibilities behind prop chains.

Secret mission

Add a Severity Filter

Add an independent severity filter that composes with the existing status filter. You will keep selection valid as both controls reshape the incident list.

Clean Up Your Resources

Clean Up Your Resources

Choose whether to keep the local project active, pause the development server for later, or delete the project files. Everything runs on your computer, so there are no ongoing cloud costs.

Resources you used:

  • A local incident-operations-dashboard project folder.
  • A generated node_modules directory containing the installed dependencies.
  • A generated package-lock.json file containing the resolved dependency versions.
  • A production dist folder containing the built dashboard.

Keep everything running

No action is needed while you are actively improving the dashboard. This keeps the local server available for quick testing.

  • Leave the integrated terminal running the Vite development server open in Visual Studio Code.
  • Keep the incident-operations-dashboard folder on your computer.
  • Use the Local URL from earlier whenever you want to test the enhanced dashboard.

Pause - I'll come back to this later

Shut down the local development server to free its terminal session. Your project files stay ready for your return.

  • Switch back to the integrated terminal running the Vite development server.
  • Close that terminal to stop the development server.
  • Keep the incident-operations-dashboard folder on your computer.
  • Refresh the Local URL from earlier.

The Local URL should stop serving the dashboard. The project folder still contains your source files and generated output.

Delete - I don't want to use this again

Complete removal clears the local project files so you can start fresh.

Deleting a finished project can feel drastic. No cloud resources or accounts are attached to this folder.

  • Close Visual Studio Code to stop any remaining Vite process.
  • Press the Windows key to open search.
  • Type File Explorer into the search field.
  • Press Enter to open File Explorer.
  • Navigate to the parent folder containing incident-operations-dashboard.
  • Select the incident-operations-dashboard folder.
  • Delete the selected folder in File Explorer.
  • Complete any deletion confirmation shown by File Explorer.

You should no longer see incident-operations-dashboard in its parent folder. This removes node_modules, package-lock.json, dist, and every project source file.

Nice Work!

Nice Work!

You did it! Your React incident operations dashboard now keeps every view synchronized through one state architecture. Its successful production build proves the final module graph is valid.

You've learned how to:

  • Build a working incident console that filters incidents by status. Select incidents before acknowledging or resolving them with synchronized results.
  • Expose the duplicated-state failure that lets one status badge drift from the rest of the interface. Restore a single source of truth with a reducer. Derive every view from that state.
  • Remove prop drilling with separate state and dispatch contexts. Give components focused access through custom hooks. Confirm the architecture through development and production builds.
  • Secret Mission: Add a composable severity filter that works with the status filter. Preserve a valid selected incident as either filter changes.

Ready to quiz yourself?