Skip to content

Build typed React components

You will refactor the static Delivery Board into a typed React component tree that renders three work items from one data array. The result will use typed props, semantic list and article elements, stable React keys, an explicit empty state, and responsive card layout.

A front-end framework earns its place when it helps a team connect data, interface structure, and updates without manually synchronizing repeated DOM elements. Components give each visible part a named responsibility. Props make the data crossing each component boundary explicit.

This lesson changes the structure of the client without adding interaction. That boundary keeps the first React problem focused: given typed data, which component renders each part of the interface?

What you will practice

  • Explain how a React function component turns props into JSX.
  • Distinguish JSX syntax from HTML syntax and from the DOM nodes produced in the browser.
  • Choose component boundaries from visible responsibilities instead of splitting by line count.
  • Declare typed props and pass data from a parent component to a child component.
  • Render a data array with map and assign each sibling a stable key from the data model.
  • Keep render logic pure by treating props and imported fixture data as read-only inputs.
  • Verify the component tree through type checks, semantic inspection, responsive tests, and deliberate data variations.
  • New: React function components, component composition, TSX and JSX rules, props, parent and child components, one-way data flow, pure rendering, conditional rendering, array-to-component mapping, stable React keys, and component file boundaries.
  • Reused: The Delivery Board repository, Vite, TypeScript types, modules, arrays, objects, map, semantic HTML, Sass, terminal checks, browser developer tools, branches, and the Stage 1 WorkItem model.

Starting point

Before you start

  • The completed Project stage 1 delivery-board repository with its accepted changes on main.
  • A clean working tree and access to the private remote used for the project.
  • Node.js 24 LTS, npm, VS Code, and a current browser.
  • The existing WorkStatus and WorkItem types, one sampleWorkItem fixture, App component, and styles.scss file.
  • No React state, event handler, route, API request, or automated component test is required yet.
Current state
App reads one imported WorkItem object and renders the complete static interface in one component. The data is typed, but the interface cannot yet render a collection through reusable component boundaries.
First action
Open the delivery-board repository, confirm that the working tree is clean, synchronize main, run the existing checks, and create feature/react-components.
First checkpoint
The branch contains three valid work-item fixtures and the page renders them through WorkItemList and WorkItemCard without changing the WorkItem type.
Help trigger
Use the nearest recovery note or ask for help if the baseline checks fail before your change, TSX reports an unexplained syntax error, a prop type error remains after the component call matches its declaration, React reports a key warning, or the card layout causes horizontal page scrolling.

You have completed the lesson when:

  • feature/react-components begins from the verified Stage 1 main branch;
  • src/data/sample-work-items.ts exports three fictional values through one WorkItem[] annotation;
  • every work item has a unique, stable id, and every label is unique within its work item;
  • WorkItemCard receives one WorkItem through typed props and renders its complete article;
  • WorkItemList receives a read-only array prop and maps each work item to one semantic list item;
  • the JSX element directly created for each work item uses item.id as its React key;
  • App composes the product header and WorkItemList without rendering work-item fields itself;
  • zero work items produce the visible text No work items are available. instead of an empty region;
  • no component changes an imported array, a prop object, or a prop array while rendering;
  • the page has one main, one page h1, one collection h2, one article and h3 per work item, a description list for facts, and a semantic list for labels;
  • the three-card result works at 320 CSS pixels and 200% browser zoom without clipped required content or horizontal page scrolling;
  • the restored source passes npm run typecheck, npm run lint, and npm run build without an application error or React warning in the browser Console; and
  • one focused commit records the completed lesson state on the feature branch.

Do not diagnose a previous unfinished change as part of the component lesson. Start from the accepted Stage 1 result.

Synchronize and verify the baseline
git switch main
git pull --ff-only
git status
npm ci
npm run typecheck
npm run lint
npm run build
git switch -c feature/react-components

git status must report a clean working tree before the branch command. If a baseline command fails, stay on main, record the first failure, and repair or ask about the Stage 1 state before you create the lesson branch.

If feature/react-components already exists, do not create a second branch with a similar name. Run git branch --list, inspect the existing branch, and continue it only when it contains your intended lesson work.

Read the current interface as one component

Section titled “Read the current interface as one component”

The Stage 1 App component has several visible responsibilities:

  1. It defines the page shell and product heading.
  2. It reads the current collection of work items.
  3. It decides what the collection region displays.
  4. It renders every field for one work item.

One component can perform all four jobs, but repeated items make the boundary costly. Adding a second card would repeat the complete section markup. Changing the fact layout would require the same edit in every repeated copy.

Use three components instead:

Component Input Visible responsibility
App Imported project data Compose the page shell, product header, and collection component
WorkItemList Array of work items Render the collection heading, empty state, semantic list, and stable item keys
WorkItemCard One work item Render one article with its title, description, facts, labels, and label count

This split follows the visible interface. It does not create a component for every div, heading, or paragraph.

In Level 2, your JavaScript selected DOM elements and used methods such as createElement, textContent, append, and replaceChildren. You decided each DOM operation and when to run it.

React keeps the same core relationship between data and visible output, but it changes your immediate task:

Level 2 DOM code React component code
Select the existing region Render a component inside the React root
Create and update individual DOM nodes Return JSX that describes the required output
Call a render function after a state change Let React evaluate the relevant component tree after a state update
Keep records in arrays and objects Keep records in arrays and objects
Use stable record IDs to find the correct item Use stable record IDs as keys and later for state updates and routes

React does not remove JavaScript, HTML semantics, or the DOM. It gives the application a component model and a renderer that reconciles the new component output with the current DOM.

A PHP template can also receive data, loop over records, and reuse a partial or function for repeated markup. In a typical server-rendered PHP request, PHP runs on the server and sends produced HTML to the browser. In this Vite application, React components run in the browser, and later client-side state updates can cause React to evaluate the component tree again without requesting a complete new page.

Both approaches benefit from clear input and rendering responsibilities. TypeScript prop types check the React client during development. They do not replace server-side validation or runtime checks for external data.

A .tsx file can contain TypeScript and JSX. JSX is a syntax extension that lets source code describe element structure with tag-like notation.

A small typed component
type ProjectSummaryProps = {
name: string;
itemCount: number;
};
export function ProjectSummary({ name, itemCount }: ProjectSummaryProps) {
return (
<section>
<h2>{name}</h2>
<p>{itemCount} work items</p>
</section>
);
}

Read the example in this order:

  1. ProjectSummaryProps defines the component input.
  2. ProjectSummary is a function with a capitalized name.
  3. The function destructures name and itemCount from one props object.
  4. The function returns JSX.
  5. Curly braces in JSX evaluate JavaScript expressions.
  6. Vite transforms the TSX for the browser. The browser does not parse this source file as HTML.
Requirement JSX example Reason
Capitalize custom component names <WorkItemCard /> A lowercase tag name represents a built-in HTML element
Close every element <WorkItemCard /> and <li></li> JSX requires explicit closing syntax
Return one root value from a component <article>...</article> One JSX expression must contain the returned structure
Use className for an HTML class <article className="work-item"> class is not the JSX property name
Put JavaScript expressions in braces <h3>{item.title}</h3> Braces move from JSX markup into JavaScript evaluation
Keep HTML semantics <ul>, <li>, <article>, <dl> JSX does not make a generic div more meaningful

An expression produces a value. item.title, items.length, and items.map(...) are expressions, so they can appear inside JSX braces. An if statement does not produce a value, so use it before return or use a conditional expression inside JSX.

Expand the fixture into a typed collection

Section titled “Expand the fixture into a typed collection”

Rename the singular fixture file:

Rename the fixture module
git mv src/data/sample-work-item.ts src/data/sample-work-items.ts

Replace its content with three fictional records:

src/data/sample-work-items.ts
import type { WorkItem } from "../types/work-item";
export const sampleWorkItems: WorkItem[] = [
{
id: "WI-001",
title: "Prepare the accessible navigation review",
description:
"Confirm the keyboard path, current-page state, and narrow layout before integration.",
status: "planned",
labels: ["frontend", "accessibility"],
},
{
id: "WI-002",
title: "Document the component responsibilities",
description:
"Record which component owns the collection, one work item, and the empty result.",
status: "active",
labels: ["frontend", "documentation"],
},
{
id: "WI-003",
title: "Verify the production build",
description:
"Run the required checks and inspect the built client at narrow and normal widths.",
status: "done",
labels: ["tooling", "quality"],
},
];

The WorkItem[] annotation checks every array member against the existing model. Do not add a second type that describes the same records.

The data has two identity rules:

  • every work item id is unique in the collection; and
  • every label string is unique within one work item’s labels array.

The lesson uses these stable values as React keys. The future API boundary must validate the same rules at runtime because a TypeScript annotation cannot validate received JSON.

Change the second fixture’s status to "review", then run:

Confirm that the model rejects an unsupported status
npm run typecheck

The command must reject "review" because WorkStatus permits only "planned", "active", and "done". Restore "active" and confirm that type checking passes.

If the invalid value passes, inspect the array annotation. It must be WorkItem[], not any[], and the object must not use a type assertion.

Create one folder for reusable application components:

Create the component folder
New-Item -ItemType Directory src/components

If the folder already exists, PowerShell can report that the item exists. Keep the existing folder and inspect its contents before adding files.

The required source structure will become:

  • Directorysrc/
    • Directorycomponents/
      • WorkItemCard.tsx — Render one work-item article
      • WorkItemList.tsx — Render the collection or its empty state
    • Directorydata/
      • sample-work-items.ts — Provide the trusted internal fixture array
    • Directorytypes/
      • work-item.ts — Define WorkStatus and WorkItem
    • App.tsx — Compose the product page
    • main.tsx — Render App into the fixed browser root
    • styles.scss — Style the complete page

Keep the type, fixture, and component roles separate. A component can import the model it needs, but the reusable type file must not import a React component.

Create src/components/WorkItemCard.tsx:

src/components/WorkItemCard.tsx
import type { WorkItem, WorkStatus } from "../types/work-item";
type WorkItemCardProps = {
item: WorkItem;
};
const statusLabels: Record<WorkStatus, string> = {
planned: "Planned",
active: "Active",
done: "Done",
};
function formatLabelCount(count: number): string {
return `${count} ${count === 1 ? "label" : "labels"}`;
}
export function WorkItemCard({ item }: WorkItemCardProps) {
return (
<article className="work-item">
<h3>{item.title}</h3>
<p>{item.description}</p>
<dl className="work-item-facts">
<div>
<dt>Identifier</dt>
<dd>
<code>{item.id}</code>
</dd>
</div>
<div>
<dt>Status</dt>
<dd>{statusLabels[item.status]}</dd>
</div>
</dl>
<h4>Labels</h4>
<ul className="label-list">
{item.labels.map((label) => (
<li key={label}>{label}</li>
))}
</ul>
<p>{formatLabelCount(item.labels.length)} total.</p>
</article>
);
}

WorkItemCardProps is the public TypeScript boundary for this component. A caller must supply an item prop whose value matches WorkItem.

The component call and the received prop
<WorkItemCard item={currentItem} />
export function WorkItemCard({ item }: WorkItemCardProps) {
// item is a WorkItem in this function.
}

React passes one props object to the component function. The parameter syntax destructures its item property. This is normal JavaScript destructuring with a TypeScript type annotation on the complete parameter.

Record<WorkStatus, string> requires one string label for every supported status. If you later add a status to WorkStatus, TypeScript will identify the missing display label here.

formatLabelCount and statusLabels stay outside the component because they do not depend on a component instance. The function receives a value, returns a string, and changes no external value.

Do not change item, item.labels, or the imported fixture during rendering.

Do not mutate a prop while rendering
export function WorkItemCard({ item }: WorkItemCardProps) {
item.labels.push("rendered"); // Incorrect: changes data received from the parent.
return <article>{item.title}</article>;
}

React expects a component to produce the same output for the same props. Vite’s generated StrictMode wrapper can run render logic more than once during development to expose impure behavior. A component must still produce correct output when React evaluates it again.

Preserve semantic structure inside the component

Section titled “Preserve semantic structure inside the component”

The component boundary does not appear in the browser DOM. The elements returned by the component do:

  • article identifies one self-contained work item;
  • h3 follows the collection’s h2;
  • dl, dt, and dd connect fact names with values;
  • ul and li identify the labels as a list; and
  • visible status text communicates status without relying on color.

Do not add role="article" to the article element or role="list" to the ul. The semantic elements already provide those roles.

Create src/components/WorkItemList.tsx:

src/components/WorkItemList.tsx
import type { WorkItem } from "../types/work-item";
import { WorkItemCard } from "./WorkItemCard";
type WorkItemListProps = {
items: readonly WorkItem[];
};
export function WorkItemList({ items }: WorkItemListProps) {
if (items.length === 0) {
return (
<section className="work-items">
<h2>Current work</h2>
<p>No work items are available.</p>
</section>
);
}
return (
<section className="work-items">
<h2>Current work</h2>
<ul className="work-item-list">
{items.map((item) => (
<li key={item.id}>
<WorkItemCard item={item} />
</li>
))}
</ul>
</section>
);
}

The prop type uses readonly WorkItem[]. A normal WorkItem[] can cross this boundary, but WorkItemList cannot call a mutating array method such as push on the received collection. This is not deep immutability: the team must still treat each received work-item object and its nested labels as read-only during rendering.

The early if handles one complete interface state. The main return can then describe the non-empty state without a nested conditional around every item.

map calls its callback once for each work item and returns a new array of JSX elements. It does not change items.

One record becomes one list item and card
{items.map((item) => (
<li key={item.id}>
<WorkItemCard item={item} />
</li>
))}

The parentheses after => provide an implicit return. If you replace them with a block body, you must add return:

Equivalent map callback with an explicit return
{items.map((item) => {
return (
<li key={item.id}>
<WorkItemCard item={item} />
</li>
);
})}

Use one form consistently. The shorter form fits because this callback performs one transformation.

React uses a key to match an array item with its previous rendered output when the collection changes. The key must be unique among its siblings and stable for that record.

item.id is the correct source because the data model already gives each work item a persistent identity. Do not use:

Unstable or position-based keys
<li key={index}>...</li>
<li key={Math.random()}>...</li>

An array index identifies a current position, not the work item. A random value changes on every render. Both choices become unsafe when later lessons insert, remove, filter, or reorder work items.

The key belongs on the JSX element created directly inside map. React consumes it and does not pass it to WorkItemCard. The card receives item.id as part of its normal item prop when it needs the visible identifier.

The label list uses label as its key because this project’s data rule makes label strings unique within one work item. If the product later permits duplicate display names, the label model must gain a separate stable identifier.

Replace src/App.tsx:

src/App.tsx
import { WorkItemList } from "./components/WorkItemList";
import { sampleWorkItems } from "./data/sample-work-items";
function App() {
return (
<main className="app-shell">
<header className="product-header">
<p className="eyebrow">Module 3.1 project</p>
<h1>Delivery Board</h1>
<p>Review the current work before the interactive client is added.</p>
</header>
<WorkItemList items={sampleWorkItems} />
</main>
);
}
export default App;

App imports data and passes it down. WorkItemList receives the array and passes one item down. WorkItemCard renders that item. Data moves from parent to child through props.

No child reaches into App to find the fixture. This one-way data flow makes each input visible at the component call.

In WorkItemList, temporarily replace the card call:

Deliberate incorrect prop value
<WorkItemCard item={item.title} />

Run npm run typecheck. TypeScript must report that a string is not assignable to WorkItem. Restore item={item} and confirm that the command passes.

If the incorrect call passes, inspect WorkItemCardProps. Its item property must use WorkItem, not any or unknown.

Checkpoint: Typed data crosses explicit component boundaries

What now works
App passes three typed work items to WorkItemList, the list maps them through stable IDs, and WorkItemCard renders each complete semantic article. The deliberate incorrect prop fails and the restored source passes type checking.
Files changed
src/data/sample-work-items.ts, src/components/WorkItemList.tsx, src/components/WorkItemCard.tsx, src/App.tsx
What remains
Adapt the Stage 1 styles, test the empty state and identity assumptions, and complete the production verification.
Next action
Open styles.scss, remove the single-card top-margin rule, and add the collection and responsive grid rules from the next section.
If it does not work
Confirm the renamed data import, component export names, relative import paths, prop declarations, one returned JSX root, closing tags, and the first type-check error in that order.

The Stage 1 stylesheet places one .work-item after the product header. The new ul owns collection spacing, so replace this rule:

Remove the Stage 1 single-card spacing rule
.work-item {
margin-block-start: $space-6;
}

Add these rules after the shared .product-header, .work-item surface rule:

src/styles.scss — collection layout
.work-items {
margin-block-start: $space-8;
}
.work-item-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
gap: $space-6;
padding: 0;
list-style: none;
}
.work-item-list > li {
min-width: 0;
}
.work-item {
height: 100%;
}

Extend the existing text-wrapping and heading-margin selectors so they include the new heading levels:

src/styles.scss — heading and text adjustments
h1,
h2,
h3,
h4,
p,
dd,
code,
li {
overflow-wrap: anywhere;
}
h1,
h2,
h3,
h4 {
margin-block-start: 0;
}

Replace the corresponding Stage 1 selector blocks instead of keeping two competing versions.

The grid can create several columns when cards have sufficient room. minmax(min(100%, 20rem), 1fr) lets a track become no wider than the available container on a narrow screen. min-width: 0 permits a grid item to shrink instead of forcing horizontal overflow.

The semantic hierarchy remains independent of the layout. A one-column phone result and a multi-column desktop result contain the same list and article structure.

Run the development server in Terminal 1 if it is not already active:

Serve the component result
npm run dev

Open the reported URL and confirm:

  1. The page shows Delivery Board once.
  2. Current work appears once.
  3. Three cards show IDs WI-001, WI-002, and WI-003.
  4. Statuses display as Planned, Active, and Done.
  5. Every card shows two visible labels and 2 labels total.
  6. Cards form columns only when the available width supports readable cards.
  7. The Console contains no React key warning or application error.
  • If the page is blank, read the first terminal transformation error and the first Console error. Fix the earliest source error before changing CSS.
  • If the import cannot be resolved, confirm the exact plural file name, export name, capitalization, and relative path.
  • If React reports that an element type is invalid, compare named exports and named imports. WorkItemList and WorkItemCard use braces in both places.
  • If React reports a key warning, confirm that key={item.id} is on the li inside the work-item map, and confirm that all three fixture IDs differ.
  • If a card exceeds the viewport, inspect the grid track, min-width, fixed widths, and long text before adding overflow-x: hidden. Hiding overflow would conceal the defect.

Test the component assumptions deliberately

Section titled “Test the component assumptions deliberately”

A passing page with one data set is weak evidence. Change one assumption at a time, observe the expected result, and restore the submitted fixture after each test.

In App, temporarily pass an empty array:

Temporary empty collection
<WorkItemList items={[]} />

Expected result:

  • the product header remains visible;
  • Current work remains visible;
  • No work items are available. appears;
  • no empty ul appears; and
  • the Console has no application error.

Restore items={sampleWorkItems}.

Temporarily change the third fixture ID from WI-003 to WI-002. The development Console must report a duplicate-key warning for the work-item siblings. Restore WI-003 and reload. The warning must no longer appear.

A TypeScript string type cannot prove uniqueness between records. This test checks a runtime data invariant that the future API validator must enforce.

Temporarily replace one title and one label with long unbroken test strings. Check the narrow viewport and confirm that the text wraps without increasing the page width. Restore the readable fixture text.

Use DevTools to inspect the final structure. The React component names appear in React tooling when installed, but the Elements panel shows the produced semantic HTML. Confirm the final page contains:

Required semantic outline
main
header
h1
section
h2
ul
li
article
h3
dl
h4
ul

The repeated li > article branch must occur three times. This outline describes required relationships; it does not require these elements to be direct children when the supplied code includes additional meaningful wrappers.

Checkpoint: The component interface handles collection variations

What now works
The three-card layout wraps at narrow widths, the empty array produces explicit text, duplicate identity produces a deliberate warning, restored unique IDs remove that warning, and long content does not widen the page.
Files changed
src/styles.scss, src/App.tsx, src/data/sample-work-items.ts, Browser Elements panel, Browser Console
What remains
Run the complete source and production checks, inspect the final diff, and record the lesson commit.
Next action
Restore all submitted fixtures, stop any temporary data experiment, and run typecheck, lint, and build from the project root.
If it does not work
Restore the exact lesson data and App call, compare the collection CSS with the required selectors, then diagnose the first remaining source, layout, or Console failure.

Run the source checks from the project root:

Verify the component source
npm run typecheck
npm run lint
npm run build

Start the production preview only after all three commands pass:

Preview the production build
npm run preview

Use the exact URL reported by Vite. Verify:

  • a normal desktop width;
  • 320 CSS pixels wide;
  • 200% browser zoom;
  • keyboard scrolling through the complete document;
  • the heading and semantic list structure in the Elements panel;
  • all three work-item fields and label lists;
  • no clipped or overlapping required text;
  • no horizontal page scrollbar; and
  • no React warning or red application error in the Console.

Stop the preview with Ctrl+C after the checks.

Review the work before staging it:

Inspect the lesson change
git status --short
git diff

The expected source change is:

  • one renamed data file;
  • two new component files;
  • one changed App.tsx; and
  • one changed styles.scss.

Generated dist output and node_modules must remain outside the diff.

Stage the exact paths and inspect the staged diff:

Stage and commit the component result
git add src/App.tsx src/styles.scss src/components/WorkItemCard.tsx src/components/WorkItemList.tsx
git add src/data/sample-work-item.ts src/data/sample-work-items.ts
git diff --cached
git commit -m "feat: render work items with typed components"
git status

Staging both the old and new fixture paths lets Git record the rename even if its similarity detection displays the change as a delete and an add. The final git status must report a clean working tree on feature/react-components.

Do not merge this branch as an unreviewed shortcut. The next lessons continue from this component result, and Project stage 2 will define the required review and integration evidence.

Self-check

Complete these checks against the required result.

  1. Confirm that the lesson branch began from the accepted and verified Stage 1 main branch.
  2. Point to the separate model, fixture, page, collection, item, entry, and style files and state the responsibility of each.
  3. Confirm that sampleWorkItems has a WorkItem[] annotation, three unique IDs, and unique labels within each work item.
  4. Change one status to review, confirm that type checking fails, restore the supported status, and confirm that type checking passes.
  5. Point to WorkItemCardProps and explain how the item prop crosses from the component call into the function parameter.
  6. Pass item.title to WorkItemCard, confirm that type checking rejects the string, restore item, and confirm that type checking passes.
  7. Confirm that WorkItemList receives readonly WorkItem[] and does not sort, push into, or otherwise change its prop array.
  8. Point to the map callback, stable item.id key, and separate item prop and explain the responsibility of each.
  9. Confirm that label strings are valid keys only because each item keeps its labels unique.
  10. Pass an empty array and confirm that the visible empty message replaces the semantic work-item list without an error.
  11. Create a temporary duplicate work-item ID, observe the React key warning, restore unique IDs, and confirm that the warning is gone.
  12. Inspect the final DOM and confirm one main, one h1, one collection h2, and three semantic list-item and article branches with h3 headings.
  13. Confirm that each card uses visible status text, a description list for facts, and a semantic list for labels.
  14. Verify the restored result at 320 CSS pixels and 200 percent zoom with no clipped content or horizontal page scrolling.
  15. Run typecheck, lint, and build and inspect the production preview without an application error or React warning.
  16. Inspect the focused Git diff and confirm that the final lesson commit leaves the feature branch clean.

Explanation: component boundaries form an interface contract

Section titled “Explanation: component boundaries form an interface contract”

The finished source separates three kinds of change:

  • A change to the reusable work-item data shape belongs in work-item.ts.
  • A change to how one work item looks belongs in WorkItemCard.
  • A change to collection behavior, such as an empty result or future filtering, belongs in WorkItemList or its nearest state-owning parent.
  • A change to page composition belongs in App.

Typed props connect these responsibilities. If one component starts needing many unrelated props, or a child must know how its parent stores unrelated state, reassess the boundary. A useful component has a focused visible job and an input surface that describes that job.

React components remain JavaScript functions. TypeScript checks their declared inputs, Vite transforms their TSX, React evaluates their output, and React DOM applies the required browser changes. None of those tools replaces semantic HTML, runtime validation, or manual accessibility checks.

The required lesson is complete before these routes. Use a separate commit for any selected extension.

The required result is safely paused when the three typed cards and empty state work, all final checks pass, the deliberate failures are restored, the component commit exists, and git status is clean on feature/react-components.

The next lesson adds events and React state to this same component result. It will decide which component owns changing data and will keep derived values out of state.