Build and Explain a Small React App
Introduction
This is lesson 5 and the final lesson of chapter 5, aligned with course 5 of the Meta Front-End Developer pathway ("React Basics", domain react-basics). The earlier lessons introduced components and props, state and events, lists, conditions and controlled forms, and routing. Knowing each idea separately is not the same as combining them into a working application. This lesson is a guided build: you design, implement, test by hand, and explain a small React app called Harbor Workshops Lite.
The project is original to OnLevelUp and uses only invented data. It is deliberately small, so you can finish it in a few hours, but it exercises every skill from the chapter. Just as important, you practice explaining your decisions, a skill that the certification pathway's projects and technical interviews reward. Official completion of the Meta program happens on Coursera; this lesson prepares you to do that work confidently.
Learning objectives
- Turn a short product brief into screens, components, state, and routes.
- Choose where each piece of state should live and justify it.
- Implement the app in small, testable steps.
- Combine lists, conditional rendering, controlled forms, and routing in one project.
- Review the finished app against a checklist for correctness, accessibility, and structure.
- Debug typical mistakes using the browser and React developer tools.
- Explain the architecture in a short, clear walkthrough.
1. The brief
Harbor Workshops Lite lets visitors browse community workshops, open one, and reserve seats.
Requirements:
- A catalog page listing workshops with title, level, price, and seats left. A search box filters by title; a select filters by level. When no workshop matches, a message appears.
- A detail page for one workshop with its description, a "Reserve" form, and a link back.
- A reservation form collecting name, email, and number of seats (1 to 6, not more than seats left), validated on submit with clear error messages.
- After a successful reservation, the seats left decrease, and the user sees a confirmation page with a summary.
- A header with navigation and a not found page.
- The app is keyboard accessible, uses semantic HTML, and has meaningful alt text on images.
Out of scope: a real server, accounts, payment. Data lives in memory and resets on reload; that limitation is acceptable and you should be able to state it.
2. From brief to design
Step 1: list the screens and routes
| Screen | Path | Notes |
|---|---|---|
| Catalog | / |
search and level filter |
| Detail | /workshops/:id |
form lives here |
| Confirmation | /confirmation |
needs a summary from the previous step |
| Not found | * |
friendly message and link |
Step 2: sketch the component tree
App (Routes, owns workshops + lastReservation state)
Layout (header, Outlet, footer)
CatalogPage
Filters (query, level)
WorkshopList
WorkshopCard (xN)
DetailPage
ReservationForm
ConfirmationPage
NotFoundPage
A useful rule: draw boxes around parts that have one responsibility, then name them with nouns.
Step 3: decide the state
Ask of each value: does it change, who needs it, and can it be derived?
| Value | State? | Lives in | Reason |
|---|---|---|---|
workshops (with seatsLeft) |
Yes | App |
Changes after a reservation; used by catalog and detail |
lastReservation |
Yes | App |
Written by the form, read by the confirmation page |
query, level |
Yes | Catalog page (or URL) | Only the catalog uses them |
| Filtered list | No | Derived | Computed from workshops, query, level |
| Form fields and errors | Yes | ReservationForm |
Only the form needs them |
| Total price | No | Derived | seats * price |
The principle is to keep each state in the lowest component that needs it, and lift it only when two branches of the tree must share it. workshops must be at the top because both the catalog and the detail page read it and the form changes it.
Step 4: plan the data flow
Data flows down as props, and events flow up as callbacks.
Apppassesworkshopsto pages and a functionreserve(workshopId, booking)to the detail page.ReservationFormcallsonReserve(booking)after validating.App.reserveupdates seats with an updater function, storeslastReservation, and the form's parent navigates to/confirmation.
3. Building it in small steps
Build in order, running the app after each step. If something breaks, you know which step caused it.
Step A: data and project
Create the project with Vite and place sample data in src/data/workshops.js.
export const initialWorkshops = [
{ id: 'sourdough', title: 'Sourdough basics', level: 'beginner',
price: 45, seatsLeft: 4,
description: 'Mix, fold, and bake a loaf using a wild starter.' },
{ id: 'pottery', title: 'Wheel pottery', level: 'intermediate',
price: 60, seatsLeft: 0,
description: 'Center clay and pull a simple cylinder.' },
{ id: 'linocut', title: 'Linocut printing', level: 'beginner',
price: 38, seatsLeft: 9,
description: 'Carve a block and print a two-color card.' },
];
Step B: layout and routes
// src/App.jsx
import { useState } from 'react';
import { Routes, Route } from 'react-router';
import Layout from './components/Layout.jsx';
import CatalogPage from './pages/CatalogPage.jsx';
import DetailPage from './pages/DetailPage.jsx';
import ConfirmationPage from './pages/ConfirmationPage.jsx';
import NotFoundPage from './pages/NotFoundPage.jsx';
import { initialWorkshops } from './data/workshops.js';
export default function App() {
const [workshops, setWorkshops] = useState(initialWorkshops);
const [lastReservation, setLastReservation] = useState(null);
function reserve(workshopId, booking) {
setWorkshops(prev =>
prev.map(w =>
w.id === workshopId
? { ...w, seatsLeft: w.seatsLeft - booking.seats }
: w
)
);
setLastReservation({ workshopId, ...booking });
}
return (
<Routes>
<Route element={<Layout />}>
<Route index element={<CatalogPage workshops={workshops} />} />
<Route path="workshops/:id"
element={<DetailPage workshops={workshops} onReserve={reserve} />} />
<Route path="confirmation"
element={<ConfirmationPage reservation={lastReservation}
workshops={workshops} />} />
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>
);
}
Note how reserve uses map to build a new array with a new object for the changed workshop, and an updater function because the new value depends on the previous one.
Step C: catalog with filters
// src/pages/CatalogPage.jsx
import { useState } from 'react';
import WorkshopCard from '../components/WorkshopCard.jsx';
export default function CatalogPage({ workshops }) {
const [query, setQuery] = useState('');
const [level, setLevel] = useState('all');
const visible = workshops.filter(w =>
w.title.toLowerCase().includes(query.trim().toLowerCase()) &&
(level === 'all' || w.level === level)
);
return (
<>
<h1>Workshops</h1>
<div role="search">
<label htmlFor="q">Search</label>
<input id="q" type="search" value={query}
onChange={e => setQuery(e.target.value)} />
<label htmlFor="level">Level</label>
<select id="level" value={level} onChange={e => setLevel(e.target.value)}>
<option value="all">All levels</option>
<option value="beginner">Beginner</option>
<option value="intermediate">Intermediate</option>
</select>
</div>
{visible.length === 0 ? (
<p>No workshops match your search.</p>
) : (
<ul className="grid">
{visible.map(w => (
<li key={w.id}><WorkshopCard workshop={w} /></li>
))}
</ul>
)}
</>
);
}
// src/components/WorkshopCard.jsx
import { Link } from 'react-router';
export default function WorkshopCard({ workshop }) {
const { id, title, level, price, seatsLeft } = workshop;
return (
<article>
<h2><Link to={`/workshops/${id}`}>{title}</Link></h2>
<p>{level} - {price} EUR</p>
<p>{seatsLeft === 0 ? 'Sold out' : `${seatsLeft} seats left`}</p>
</article>
);
}
Check: three cards, the search narrows the list, the filter works, and an unmatched search shows the message.
Step D: detail page and reservation form
// src/pages/DetailPage.jsx
import { useParams, useNavigate, Link } from 'react-router';
import ReservationForm from '../components/ReservationForm.jsx';
import NotFoundPage from './NotFoundPage.jsx';
export default function DetailPage({ workshops, onReserve }) {
const { id } = useParams();
const navigate = useNavigate();
const workshop = workshops.find(w => w.id === id);
if (!workshop) return <NotFoundPage />;
function handleReserve(booking) {
onReserve(workshop.id, booking);
navigate('/confirmation');
}
return (
<article>
<h1>{workshop.title}</h1>
<p>{workshop.description}</p>
{workshop.seatsLeft === 0 ? (
<p role="status">This workshop is sold out.</p>
) : (
<ReservationForm seatsLeft={workshop.seatsLeft}
price={workshop.price}
onReserve={handleReserve} />
)}
<p><Link to="/">Back to the catalog</Link></p>
</article>
);
}
// src/components/ReservationForm.jsx
import { useState } from 'react';
const emptyForm = { name: '', email: '', seats: 1 };
export default function ReservationForm({ seatsLeft, price, onReserve }) {
const [form, setForm] = useState(emptyForm);
const [errors, setErrors] = useState({});
function handleChange(event) {
const { name, value } = event.target;
setForm(prev => ({ ...prev, [name]: value }));
}
function validate(values) {
const found = {};
const seats = Number(values.seats);
if (values.name.trim().length < 2) found.name = 'Enter your full name.';
if (!/^\S+@\S+\.\S+$/.test(values.email)) found.email = 'Enter a valid email.';
if (!Number.isInteger(seats) || seats < 1 || seats > Math.min(6, seatsLeft)) {
found.seats = `Choose between 1 and ${Math.min(6, seatsLeft)} seats.`;
}
return found;
}
function handleSubmit(event) {
event.preventDefault();
const found = validate(form);
setErrors(found);
if (Object.keys(found).length === 0) {
onReserve({ ...form, seats: Number(form.seats) });
}
}
const total = (Number(form.seats) || 0) * price;
return (
<form onSubmit={handleSubmit} noValidate>
<Field id="name" label="Full name" error={errors.name}>
<input id="name" name="name" value={form.name} onChange={handleChange} />
</Field>
<Field id="email" label="Email" error={errors.email}>
<input id="email" name="email" type="email"
value={form.email} onChange={handleChange} />
</Field>
<Field id="seats" label="Seats" error={errors.seats}>
<input id="seats" name="seats" type="number" min="1"
value={form.seats} onChange={handleChange} />
</Field>
<p>Total: {total} EUR</p>
<button type="submit">Reserve</button>
</form>
);
}
function Field({ id, label, error, children }) {
return (
<div>
<label htmlFor={id}>{label}</label>
{children}
{error && <p id={`${id}-error`} role="alert">{error}</p>}
</div>
);
}
Field is a small component extracted to avoid repeating the label and error markup. The total is derived, not stored. Validation checks seats against the real remaining seats.
A refinement you can add: give the input aria-invalid and aria-describedby when there is an error, as in lesson 3.
Step E: confirmation and not found
// src/pages/ConfirmationPage.jsx
import { Link } from 'react-router';
export default function ConfirmationPage({ reservation, workshops }) {
if (!reservation) {
return (
<>
<h1>No reservation yet</h1>
<p><Link to="/">Browse workshops</Link></p>
</>
);
}
const workshop = workshops.find(w => w.id === reservation.workshopId);
return (
<>
<h1>Reservation confirmed</h1>
<p>
Thank you, {reservation.name}. You reserved {reservation.seats} seat(s)
for {workshop?.title}. A confirmation will be sent to {reservation.email}.
</p>
<p><Link to="/">Back to the catalog</Link></p>
</>
);
}
The guard for a missing reservation matters: opening /confirmation directly (or after a reload) must not crash. The ?. operator protects against an unknown workshop.
4. Reviewing your own work
Professional developers review before sharing. Go through this list.
Behavior
- Does every requirement from the brief work, including edge cases (sold out, 0 seats typed, text in the number field, search with capital letters)?
- Does the Back button behave sensibly?
- Does a reload at each URL produce a sensible page (with the hosting fallback)?
Code quality
- Are components small, named with nouns, and one per file?
- Is any state duplicated or derivable?
- Are keys stable ids?
- Are there leftover
console.logcalls, unused imports, or commented-out code?
Accessibility
- Can you complete the whole flow using only the keyboard?
- Does every input have a label and every error a text message?
- Is the heading structure logical (one
h1per page)? - Is color contrast sufficient, and is information never conveyed by color alone?
Tools
- Open the React Developer Tools Components tab to inspect props and state of any component. Edit a value live to test a branch.
- Use the browser Console for warnings (missing keys, controlled/uncontrolled switches).
- Run
npm run buildto detect build errors, thennpm run previewto test the production bundle. - Run the linter that the Vite template provides and fix the warnings.
5. Explaining your project
Being able to explain an app shows understanding. Prepare a 2-minute walkthrough:
- Purpose in one sentence.
- Structure: the routes and main components.
- State: what you store, where, and why (mention what you chose to derive).
- One tricky problem and how you solved it (for example, lifting
workshopstoApp). - Limits and next steps: data is lost on reload, no server validation, no automated tests yet.
Sample: "Harbor Workshops Lite lets visitors browse and reserve community workshops. It has four routes: catalog, detail, confirmation, and not found. The workshop list lives in App because the catalog reads it and the reservation form changes it. Filters and form fields are local, and the filtered list and the total price are derived. A challenge was keeping seat counts consistent, which I solved by updating immutably with an updater function. The next steps are persisting data with an API and adding tests."
Worked example: adding a "Cancel reservation" feature
Problem. Extend the app so that the confirmation page has a "Cancel reservation" button that gives the seats back and clears the reservation. Decide where the code goes.
Step 1: identify what changes. Cancelling must increase seatsLeft (state in App) and set lastReservation to null (also in App). So the logic belongs in App, exposed as a callback.
Step 2: add the function.
function cancelReservation() {
if (!lastReservation) return;
setWorkshops(prev =>
prev.map(w =>
w.id === lastReservation.workshopId
? { ...w, seatsLeft: w.seatsLeft + lastReservation.seats }
: w
)
);
setLastReservation(null);
}
Step 3: pass it down. <ConfirmationPage ... onCancel={cancelReservation} />.
Step 4: use it.
<button type="button" onClick={onCancel}>Cancel reservation</button>
Step 5: trace. Sourdough starts with 4 seats. The visitor reserves 2: seats 2, reservation set. On the confirmation page they cancel: seats return to 4, the reservation becomes null, and the same page now shows "No reservation yet". Reopening the catalog shows "4 seats left".
Why this design, and why not others? Keeping both writes in one function in App guarantees the two pieces of state change together. Putting the setWorkshops call in the confirmation page would require passing setWorkshops down, exposing the state's internals. Calling two setters inside an event handler is safe because React batches them into one render. Reading lastReservation inside the updater would be fine, but copying its values first is clearer. And button type="button" avoids an accidental form submit if the button is ever placed inside a form.
Common pitfalls
- Putting all state in
Appout of habit, causing many props; keep local state local. - Duplicating data, such as a separate
filteredWorkshopsstate. - Forgetting guards for missing params, missing reservation, or empty lists.
- Not validating against business limits (seats left), only against format.
- Mutating the workshops array when reducing seats.
- Assuming
useNavigatedata survives reloads. - Huge components that mix data, logic and markup; extract
Field, cards, and rows. - Skipping the keyboard test and shipping inaccessible forms.
- Not running a production build before declaring the app finished.
Exam tip: In project-based assessments, graders look for working requirements, clean component structure, immutability, accessibility basics, and explanations. When unsure where state belongs, choose the lowest common parent of every component that reads or writes it. Mention what is derived. Keep your explanation concrete: name the files and the data flow.
Mini case study: the stale confirmation
Situation. After the first release, a tester reports: "I reserved 3 seats, saw the confirmation, then reserved another workshop. The confirmation still shows the previous workshop's name for a moment, and one time the app crashed when I opened /confirmation right after reloading the page."
The relevant code is:
function ConfirmationPage({ reservation, workshops }) {
const workshop = workshops.find(w => w.id === reservation.workshopId);
return <h1>Confirmed: {workshop.title}</h1>;
}
Questions. (1) What causes the crash? (2) What could cause stale data? (3) What would you change?
Solution.
- After a reload, in-memory state is reset, so
lastReservationisnull. Readingreservation.workshopIdthrows aTypeError. The component needs a guard and a friendly fallback, as in Step E. - Stale display usually comes from copying props into state (
useState(reservation)) or storing a separate summary at the wrong time. Rendering directly from thereservationprop and deriving the workshop on each render keeps the page consistent with the source of truth. - Add the early return for
!reservation, use optional chaining for the workshop lookup, and decide whether the reservation should survive reloads. If it should, persist it (for example, withlocalStorage, read in a lazy state initializer) or move to a real backend. Document the choice in the project notes.
Key terms
- Brief: a short description of what a product must do.
- Component tree: the hierarchy of components in an app.
- Source of truth: the single place where a piece of data is stored.
- Lifting state: moving state to a shared ancestor.
- Callback prop: a function passed down so a child can notify its parent.
- Derived value: data computed from existing state instead of stored.
- Guard clause: an early check that handles missing or invalid data.
- React Developer Tools: a browser extension to inspect components, props, and state.
- Walkthrough: a short spoken explanation of how a project works.
- Production build: the optimized output created by
npm run build.
Summary checklist
- I can convert a brief into screens, routes, components, and state.
- I place each state in the lowest component that needs it.
- I derive filtered lists and totals instead of storing them.
- I update arrays and objects immutably, with updater functions when needed.
- I validate form data against format and business rules.
- I guard against missing params, missing data, and empty lists.
- I test the flow with the keyboard and review accessibility basics.
- I use developer tools and a production build to verify the app.
- I can explain the architecture in two minutes.
Official sources
- Meta Front-End Developer Professional Certificate (opens in a new tab)
- Course 5: React Basics (opens in a new tab)
- React: Thinking in React (opens in a new tab)
- React: Choosing the State Structure (opens in a new tab)
- React: Sharing State Between Components (opens in a new tab)
- React Developer Tools (opens in a new tab)
- React Router documentation (opens in a new tab)
