Skip to content

React Components, State, and Navigation

Step 23 of 45

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

  1. Turn a short product brief into screens, components, state, and routes.
  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 community workshops, open one, and reserve seats.

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

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.

  • App passes workshops to pages and a function reserve(workshopId, booking) to the detail page.
  • ReservationForm calls onReserve(booking) after validating.
  • App.reserve updates seats with an updater function, stores lastReservation, 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.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.

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

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.

  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. Stale display usually comes from copying props into state (useState(reservation)) or storing a separate summary at the wrong time. 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