Guided React Project: Build a Workshop Booking Preview
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). Combine reusable React components and props with state, 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 product brief into components, state, and React Router routes and URL state.
- 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 fictional workshops and simulate one active reservation at a time. Cancel the current preview before creating another; real inventory is never reserved.
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
NotFoundPageA 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, inside bookingState | App |
Changes after a reservation; used by catalog and detail |
lastReservation |
Yes, inside bookingState | 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.reserve checks the current local snapshot, returns false on rejection, or replaces bookingState with both the updated workshops and reservation. The parent navigates only after acceptance.
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 [bookingState, setBookingState] = useState({
workshops: initialWorkshops, lastReservation: null,
});
const { workshops, lastReservation } = bookingState;
function reserve(workshopId, booking) {
const workshop = workshops.find(w => w.id === workshopId);
const seats = Number(booking.seats);
if (lastReservation || !workshop || !Number.isInteger(seats) ||
seats < 1 || seats > Math.min(6, workshop.seatsLeft) ||
typeof booking.name !== 'string' || !booking.name.trim() ||
typeof booking.email !== 'string' || !booking.email.trim()) return false;
// One synchronous local action per submit; both values change together.
setBookingState({
workshops: workshops.map(w => w.id === workshopId
? { ...w, seatsLeft: w.seatsLeft - seats } : w),
lastReservation: {
workshopId, name: booking.name.trim(), email: booking.email.trim(), seats,
},
});
return true;
}
return (
<Routes>
<Route element={<Layout />}>
<Route index element={<CatalogPage workshops={workshops} />} />
<Route path="workshops/:id"
element={<DetailPage workshops={workshops} hasReservation={Boolean(lastReservation)} onReserve={reserve} />} />
<Route path="confirmation"
element={<ConfirmationPage reservation={lastReservation}
workshops={workshops} />} />
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>
);
}The local flow has one synchronous action per submit. map creates a new array and replaces only the changed workshop; one state object keeps its seat count and reservation summary together. This snapshot-based handler is not a concurrent inventory transaction. An asynchronous or multi-user application needs an authoritative server result; queued local transitions can use an updater or the reducer pattern in chapter 6.
Use the version-8 BrowserRouter entry point from lesson 4 once, outside App, and install react-router@8 in this standalone Vite project. The following shared files complete the imports used below. Route pages render content inside Layout's single main region.
// src/components/Layout.jsx
import { useEffect, useRef } from 'react';
import { NavLink, Outlet, useLocation } from 'react-router';
export default function Layout() {
const main = useRef(null);
const { pathname } = useLocation();
useEffect(() => { main.current?.focus(); }, [pathname]);
return (
<>
<a href="#main">Skip to main content</a>
<header>
<nav aria-label="Main">
<NavLink to="/" end>Catalog</NavLink>
<NavLink to="/confirmation">Reservation preview</NavLink>
</nav>
</header>
<main id="main" ref={main} tabIndex={-1}><Outlet /></main>
<footer>Harbor Workshops Lite — fictional data</footer>
</>
);
}// src/pages/NotFoundPage.jsx
import { Link } from 'react-router';
export default function NotFoundPage() {
return (
<>
<title>Page not found | Harbor Workshops Lite</title>
<h1>Page not found</h1>
<Link to="/">Back to the catalog</Link>
</>
);
}React 19 moves a rendered title element into the document head. Use one per active page and remove Vite's placeholder title from index.html. The small effect focuses the main region on pathname changes; it does not move focus for every keystroke. Keep its outline visible and verify keyboard and screen-reader behavior. Refs/effects are explained in chapter 6.
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 (
<>
<title>Workshop catalog | Harbor Workshops Lite</title>
<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} ${seatsLeft === 1 ? 'seat' : '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, hasReservation, onReserve }) {
const { id } = useParams();
const navigate = useNavigate();
const workshop = workshops.find(w => w.id === id);
if (!workshop) return <NotFoundPage />;
function handleReserve(booking) {
const accepted = onReserve(workshop.id, booking);
if (accepted) navigate('/confirmation');
return accepted;
}
return (
<article>
<title>{`${workshop.title} | Harbor Workshops Lite`}</title>
<h1>{workshop.title}</h1>
<p>{workshop.description}</p>
{hasReservation ? (
<p>Cancel your active preview on the <Link to="/confirmation">confirmation page</Link> before booking again.</p>
) : workshop.seatsLeft === 0 ? (
<p role="status">This workshop is sold out.</p>
) : (
<ReservationForm key={workshop.id} seatsLeft={workshop.seatsLeft}
price={workshop.price}
onReserve={handleReserve} />
)}
<p><Link to="/">Back to the catalog</Link></p>
</article>
);
}// src/components/ReservationForm.jsx
import { useState, useEffect, useRef } from 'react';
const emptyForm = { name: '', email: '', seats: '1' };
export default function ReservationForm({ seatsLeft, price, onReserve }) {
const [form, setForm] = useState(emptyForm);
const [errors, setErrors] = useState({});
const focusTarget = useRef(null);
const maxSeats = Math.min(6, seatsLeft);
useEffect(() => {
if (focusTarget.current) {
document.getElementById(focusTarget.current)?.focus();
focusTarget.current = null;
}
}, [errors]);
function handleChange(event) {
const { name, value } = event.target;
setForm(prev => ({ ...prev, [name]: value }));
setErrors(prev => {
const next = { ...prev };
delete next[name];
delete next.form;
return next;
});
}
function handleSubmit(event) {
event.preventDefault();
const found = {};
const seats = Number(form.seats);
if (!form.name.trim()) found.name = 'Enter your name.';
if (!event.currentTarget.elements.namedItem('email').validity.valid) {
found.email = 'Enter an email address such as name@example.com.';
}
if (!form.seats.trim() || !Number.isInteger(seats) || seats < 1 || seats > maxSeats) {
found.seats = `Choose a whole number from 1 to ${maxSeats}.`;
}
const first = Object.keys(found)[0];
if (first) focusTarget.current = `reservation-${first}`;
setErrors(found);
if (first) return;
const accepted = onReserve({ name: form.name.trim(), email: form.email.trim(), seats });
if (!accepted) setErrors({ form: 'Reservation preview unavailable. Check availability and any active preview.' });
}
const count = Number(form.seats);
const validCount = form.seats.trim() !== '' && Number.isInteger(count) && count >= 1 && count <= maxSeats;
return (
<form onSubmit={handleSubmit} noValidate>
<Field id="reservation-name" name="name" label="Full name" required autoComplete="name"
value={form.name} onChange={handleChange} error={errors.name} />
<Field id="reservation-email" name="email" label="Email address" type="email" required autoComplete="email"
value={form.email} onChange={handleChange} error={errors.email} />
<Field id="reservation-seats" name="seats" label={`Seats (1 to ${maxSeats})`}
type="number" min="1" max={maxSeats} step="1" required
value={form.seats} onChange={handleChange} error={errors.seats} />
<p>{validCount ? `Total: ${(count * price).toFixed(2)} EUR` : 'Choose a valid seat count to see the total.'}</p>
<p role="alert">{errors.form || ''}</p>
<button type="submit">Preview reservation</button>
</form>
);
}
function Field({ id, label, error, ...inputProps }) {
return (
<div>
<label htmlFor={id}>{label}</label>
<input {...inputProps} id={id} aria-invalid={Boolean(error)}
aria-describedby={error ? `${id}-error` : undefined} />
{error && <p id={`${id}-error`}>{error}</p>}
</div>
);
}Field owns both the input and its associated error attributes, so the relationship cannot be forgotten by a caller. The ref/effect pair focuses the first invalid field after its message is committed; chapter 6 covers those Hooks in detail. The email validity check does not prove ownership or deliverability. The total is derived only for a valid seat count. All data is local; a real server must validate and reserve inventory before confirming success.
Step E: confirmation and not found
// src/pages/ConfirmationPage.jsx
import { Link } from 'react-router';
export default function ConfirmationPage({ reservation, workshops }) {
if (!reservation) {
return (
<>
<title>Reservation preview | Harbor Workshops Lite</title>
<h1>No reservation yet</h1>
<p><Link to="/">Browse workshops</Link></p>
</>
);
}
const workshop = workshops.find(w => w.id === reservation.workshopId);
if (!workshop) return (
<>
<title>Unavailable workshop | Harbor Workshops Lite</title>
<h1>Workshop unavailable</h1>
<Link to="/">Back to the catalog</Link>
</>
);
return (
<>
<title>Reservation preview | Harbor Workshops Lite</title>
<h1>Reservation preview saved</h1>
<p>
Thank you, {reservation.name}. You reserved {reservation.seats} seat(s)
for {workshop?.title}. Contact email: {reservation.email}. This preview sends no email and reserves no real seats.
</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. An unknown workshop also receives a visible fallback rather than a blank title.
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() {
setBookingState(prev => {
const reservation = prev.lastReservation;
if (!reservation) return prev;
return {
workshops: prev.workshops.map(w => w.id === reservation.workshopId
? { ...w, seatsLeft: w.seatsLeft + reservation.seats } : w),
lastReservation: null,
};
});
}Step 3: pass it down. Add onCancel={cancelReservation} to the existing ConfirmationPage route element, and add onCancel to that component’s destructured props.
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 related values in one state object lets the cancellation replace them together. Passing the model setter directly to the confirmation page would expose the state’s internals instead of offering a clear onCancel operation. The updater reads the latest reservation, returns unchanged state if there is none, and has no side effects. Repeated cancellation cannot restore seats twice. Pass onCancel to ConfirmationPage as described and render the button only in its reservation-present branch. 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.
Study tip: Review working requirements, component structure, immutability, accessibility, and explanations against the rubric provided for your actual course assessment. 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. - The snippet alone does not establish a cause for a brief stale display. Inspect the parent's updates and navigation. Possible causes include copying props into state (
useState(reservation)) or deriving a separate summary before the accepted reservation is stored. 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)
- React: document title rendering (opens in a new tab)
- React: immutable array updates (opens in a new tab)
- W3C WAI: accessible form notifications (opens in a new tab)
