Skip to content

React Components, State, Forms, and Routing

Step 23 of 45

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

  1. Turn a product brief into components, state, and React Router routes and URL state.
  2. Choose where each piece of state should live and justify it.
  3. Implement the app in small, testable steps.
  4. Combine lists, conditional rendering, controlled forms, and routing in one project.
  5. Review the finished app against a checklist for correctness, accessibility, and structure.
  6. Debug typical mistakes using the browser and React developer tools.
  7. 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:

  1. 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.
  2. A detail page for one workshop with its description, a "Reserve" form, and a link back.
  3. 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.
  4. After a successful reservation, the seats left decrease, and the user sees a confirmation page with a summary.
  5. A header with navigation and a not found page.
  6. 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

Text
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, 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.

  • App passes workshops to pages and a function reserve(workshopId, booking) to the detail page.
  • ReservationForm calls onReserve(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.

JavaScript
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

JSX
// 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.

JSX
// 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>
    </>
  );
}
JSX
// 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

JSX
// 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>
      )}
    </>
  );
}
JSX
// 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

JSX
// 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>
  );
}
JSX
// 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

JSX
// 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.log calls, 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 h1 per 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 build to detect build errors, then npm run preview to 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:

  1. Purpose in one sentence.
  2. Structure: the routes and main components.
  3. State: what you store, where, and why (mention what you chose to derive).
  4. One tricky problem and how you solved it (for example, lifting workshops to App).
  5. 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.

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

JSX
<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 App out of habit, causing many props; keep local state local.
  • Duplicating data, such as a separate filteredWorkshops state.
  • 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 useNavigate data 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:

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

  1. After a reload, in-memory state is reset, so lastReservation is null. Reading reservation.workshopId throws a TypeError. The component needs a guard and a friendly fallback, as in Step E.
  2. 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 the reservation prop and deriving the workshop on each render keeps the page consistent with the source of truth.
  3. 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, with localStorage, 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