Skip to content

An Original React Booking Capstone

Step 38 of 45

Implement the Booking Workflow

Introduction

This is lesson 3 of chapter 8 (domain capstone). The catalog and detail views exist. Now you build the feature that turns the interface into an application: the booking workflow. A visitor chooses seats, fills in a form, receives clear validation feedback, submits, and sees a confirmation, while the seat count for the workshop goes down.

This is where the skills of earlier chapters meet: controlled form inputs and state (chapter 5), reducers, context, and custom hooks (chapter 6), accessible form patterns (chapters 4 and 7), and pure functions that are easy to test. The scenario is the original Harbor Workshops project; nothing here reproduces a graded assignment from the official program.

Learning objectives

By the end of this lesson you can:

  1. Build an accessible, controlled booking form with labels, hints, and autocomplete.
  2. Write a pure validation function and show errors tied to fields.
  3. Manage booking data with a reducer and share it through context.
  4. Prevent invalid or duplicate submissions and handle the "not enough seats" rule.
  5. Navigate to a confirmation page and pass the booking safely.
  6. Add loading and error states for an asynchronous submit.
  7. Persist state in localStorage defensively.
  8. Move focus and announce errors for keyboard and screen reader users.

1. Design the flow before the code

The workflow has four states:

  1. Editing: the visitor fills in the form.
  2. Invalid: a submit attempt found problems; messages are shown.
  3. Submitting: the request is in progress; the button is disabled.
  4. Success or failure: a confirmation page, or an error with a retry option.

Writing the states down prevents the most common bug in forms: a button that can be pressed twice, or a screen that shows success after a failure.

2. Pure validation

Put the rules in one plain function with no React in it:

// src/lib/validateBooking.js
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

export function validateBooking(values, seatsLeft) {
  const errors = {};

  if (!values.name.trim()) {
    errors.name = 'Enter your name.';
  }

  if (!values.email.trim()) {
    errors.email = 'Enter your email address.';
  } else if (!EMAIL.test(values.email.trim())) {
    errors.email = 'Enter an email address like name@example.com.';
  }

  const seats = Number(values.seats);
  if (!Number.isInteger(seats) || seats < 1) {
    errors.seats = 'Choose at least 1 seat.';
  } else if (seats > seatsLeft) {
    errors.seats = `Only ${seatsLeft} seats are left.`;
  }

  return errors;   // empty object means valid
}

Why a separate function?

  • It is testable without rendering anything: pass values, assert the object.
  • It is reusable on the client and, later, on a server. Client validation improves usability; server validation is the real security boundary, because the client can be bypassed. State this in your README.
  • Messages describe how to fix the problem ("Enter an email address like name@example.com") rather than only that it is wrong.

A simple email pattern is intentionally permissive. A perfect email regex does not exist; the reliable check is sending a message to the address. For a front end, a light check catches typos without rejecting valid addresses.

3. The form component

// src/components/BookingForm.jsx
import { useRef, useState } from 'react';
import { validateBooking } from '../lib/validateBooking';

const EMPTY = { name: '', email: '', seats: '1' };

export default function BookingForm({ seatsLeft, onSubmit, submitting, submitError }) {
  const [values, setValues] = useState(EMPTY);
  const [errors, setErrors] = useState({});
  const refs = { name: useRef(null), email: useRef(null), seats: useRef(null) };

  function handleChange(event) {
    const { name, value } = event.target;
    setValues((v) => ({ ...v, [name]: value }));
  }

  function handleSubmit(event) {
    event.preventDefault();
    if (submitting) return;
    const found = validateBooking(values, seatsLeft);
    setErrors(found);
    const firstInvalid = Object.keys(found)[0];
    if (firstInvalid) {
      refs[firstInvalid].current.focus();
      return;
    }
    onSubmit({
      name: values.name.trim(),
      email: values.email.trim(),
      seats: Number(values.seats),
    });
  }

  return (
    <form onSubmit={handleSubmit} noValidate>
      {Object.keys(errors).length > 0 && (
        <p role="alert" className="form-summary">
          Please fix {Object.keys(errors).length} problem(s) below.
        </p>
      )}

      <div className="field">
        <label htmlFor="name">Full name</label>
        <input
          id="name" name="name" ref={refs.name} value={values.name}
          onChange={handleChange} autoComplete="name"
          aria-invalid={Boolean(errors.name)}
          aria-describedby={errors.name ? 'name-error' : undefined}
        />
        {errors.name && <p id="name-error" className="error">{errors.name}</p>}
      </div>

      <div className="field">
        <label htmlFor="email">Email</label>
        <input
          id="email" name="email" type="email" ref={refs.email} value={values.email}
          onChange={handleChange} autoComplete="email"
          aria-invalid={Boolean(errors.email)}
          aria-describedby={errors.email ? 'email-error' : undefined}
        />
        {errors.email && <p id="email-error" className="error">{errors.email}</p>}
      </div>

      <div className="field">
        <label htmlFor="seats">Seats</label>
        <input
          id="seats" name="seats" type="number" inputMode="numeric"
          min="1" max={seatsLeft} ref={refs.seats} value={values.seats}
          onChange={handleChange}
          aria-invalid={Boolean(errors.seats)}
          aria-describedby={`seats-hint${errors.seats ? ' seats-error' : ''}`}
        />
        <p id="seats-hint" className="hint">{seatsLeft} seats left.</p>
        {errors.seats && <p id="seats-error" className="error">{errors.seats}</p>}
      </div>

      {submitError && <p role="alert" className="error">{submitError}</p>}

      <button type="submit" disabled={submitting}>
        {submitting ? 'Booking...' : 'Confirm booking'}
      </button>
    </form>
  );
}

What each decision accomplishes:

Decision Reason
htmlFor/id label pairing Click target and accessible name for every input
noValidate Disables the browser's default bubbles so your consistent, accessible messages are used
aria-invalid and aria-describedby The error is announced when the field is focused
Focus the first invalid field Keyboard users land where the problem is
role="alert" summary Announces the failure to screen readers immediately
autoComplete values Browsers can fill known fields, which is also a WCAG 2.2 criterion for identifying input purpose
disabled while submitting Prevents duplicate bookings
Controlled inputs The UI always reflects the state, and tests can type into them

A common mistake is validating on every keystroke, which shows "Invalid email" before the visitor finishes typing. Validating on submit, and then re-validating a field once it has been corrected or left (onBlur), is friendlier.

4. Booking state with a reducer and context

Bookings are needed by several screens: the booking page, the confirmation page, and the workshop screens that show seats left. Use a reducer for the transitions and context to share them.

// src/state/BookingsContext.jsx
import { createContext, useContext, useEffect, useMemo, useReducer } from 'react';

const BookingsContext = createContext(null);

function reducer(state, action) {
  switch (action.type) {
    case 'booked':
      return { ...state, bookings: [...state.bookings, action.booking] };
    case 'cancelled':
      return { ...state, bookings: state.bookings.filter((b) => b.id !== action.id) };
    default:
      return state;
  }
}

function load() {
  try {
    const raw = localStorage.getItem('harbor.bookings');
    return { bookings: raw ? JSON.parse(raw) : [] };
  } catch {
    return { bookings: [] };       // blocked storage or corrupt JSON must not crash the app
  }
}

export function BookingsProvider({ children }) {
  const [state, dispatch] = useReducer(reducer, undefined, load);

  useEffect(() => {
    try {
      localStorage.setItem('harbor.bookings', JSON.stringify(state.bookings));
    } catch {
      /* storage unavailable: keep working in memory */
    }
  }, [state.bookings]);

  const value = useMemo(() => ({ bookings: state.bookings, dispatch }), [state.bookings]);
  return <BookingsContext.Provider value={value}>{children}</BookingsContext.Provider>;
}

export function useBookings() {
  const ctx = useContext(BookingsContext);
  if (!ctx) throw new Error('useBookings must be used inside BookingsProvider');
  return ctx;
}

The third argument to useReducer, a lazy initializer, runs load once instead of on every render. Wrapping storage in try/catch is important: private browsing modes and blocked storage throw, and corrupt JSON throws on parse.

Seats taken now come from two sources: the base data plus the user's bookings. Derive it rather than storing a second counter:

// src/lib/seats.js
export function seatsLeftFor(workshop, bookings) {
  const booked = bookings
    .filter((b) => b.workshopId === workshop.id)
    .reduce((sum, b) => sum + b.seats, 0);
  return Math.max(0, workshop.capacity - workshop.seatsTaken - booked);
}

Use seatsLeftFor everywhere (cards, detail, form). One function means one rule and no disagreeing screens.

A small custom hook keeps the page tidy:

export function useSeatsLeft(workshop) {
  const { bookings } = useBookings();
  return workshop ? seatsLeftFor(workshop, bookings) : 0;
}

5. The booking page and asynchronous submit

The booking page ties the form, the data, and navigation together. A real back end would be asynchronous, so design for it now with a mock that returns a promise:

// src/api/bookings.js
export function submitBooking(payload) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (payload.seats > 10) {
        reject(new Error('Group bookings of more than 10 seats need a phone call.'));
      } else {
        resolve({ ...payload, id: `b-${Date.now()}`, createdAt: new Date().toISOString() });
      }
    }, 600);
  });
}
// src/pages/BookingPage.jsx
import { useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { getWorkshop } from '../api/workshops';
import { submitBooking } from '../api/bookings';
import { useBookings } from '../state/BookingsContext.jsx';
import { seatsLeftFor } from '../lib/seats';
import BookingForm from '../components/BookingForm.jsx';
import NotFound from './NotFound.jsx';

export default function BookingPage() {
  const { id } = useParams();
  const workshop = getWorkshop(id);
  const { bookings, dispatch } = useBookings();
  const navigate = useNavigate();
  const [submitting, setSubmitting] = useState(false);
  const [submitError, setSubmitError] = useState('');

  if (!workshop) return <NotFound message="We could not find that workshop." />;
  const left = seatsLeftFor(workshop, bookings);
  if (left === 0) {
    return (
      <>
        <h1>{workshop.title}</h1>
        <p>This workshop is fully booked.</p>
        <Link to="/workshops">Choose another workshop</Link>
      </>
    );
  }

  async function handleSubmit(values) {
    setSubmitting(true);
    setSubmitError('');
    try {
      const booking = await submitBooking({ ...values, workshopId: workshop.id });
      dispatch({ type: 'booked', booking });
      navigate('/confirmation', { state: { bookingId: booking.id }, replace: true });
    } catch (error) {
      setSubmitError(error.message || 'Something went wrong. Please try again.');
      setSubmitting(false);
    }
  }

  return (
    <>
      <h1>Book {workshop.title}</h1>
      <BookingForm
        seatsLeft={left}
        onSubmit={handleSubmit}
        submitting={submitting}
        submitError={submitError}
      />
    </>
  );
}

Notes on the flow:

  • replace: true replaces the form entry in history, so the Back button from the confirmation does not return to a filled form and invite a duplicate booking.
  • State is reset only on failure. On success the component unmounts, so calling setSubmitting(false) there would update an unmounted component.
  • Only an id is passed through router state. The confirmation page looks up the full booking from the context. Passing small identifiers rather than whole objects keeps one source of truth.
  • A second guard exists in the form (if (submitting) return;), because a double click can fire twice before React re-renders.

The confirmation page

// src/pages/Confirmation.jsx
import { Link, useLocation } from 'react-router-dom';
import { getWorkshop } from '../api/workshops';
import { useBookings } from '../state/BookingsContext.jsx';
import { formatDate, formatPrice } from '../lib/format';

export default function Confirmation() {
  const { state } = useLocation();
  const { bookings } = useBookings();
  const booking = bookings.find((b) => b.id === state?.bookingId);

  if (!booking) {
    return (
      <>
        <h1>No recent booking</h1>
        <p>Start from the workshop list to make a booking.</p>
        <Link to="/workshops">Browse workshops</Link>
      </>
    );
  }

  const workshop = getWorkshop(booking.workshopId);
  const total = workshop.priceCents * booking.seats;
  return (
    <>
      <h1 tabIndex={-1} ref={(el) => el?.focus()}>Booking confirmed</h1>
      <p>Thank you, {booking.name}. A confirmation will be sent to {booking.email}.</p>
      <dl className="facts">
        <dt>Workshop</dt><dd>{workshop.title}</dd>
        <dt>When</dt><dd>{formatDate(workshop.startsAt)}</dd>
        <dt>Seats</dt><dd>{booking.seats}</dd>
        <dt>Total</dt><dd>{formatPrice(total)}</dd>
      </dl>
      <Link to="/workshops">Back to workshops</Link>
    </>
  );
}

Two ideas matter here. First, direct visits to /confirmation (no router state) are handled with a helpful message instead of a crash. Second, moving focus to the heading after the route change tells screen reader users that new content has loaded; otherwise focus stays on the removed button and nothing is announced. The total uses integer cents multiplied by an integer, so there are no floating-point errors.

Comparison: where to keep what

Data Pattern Why not another?
Typed form values Local useState in the form Global state would re-render the app on each keystroke
Validation messages Derived from a pure function, kept in form state after submit Storing them in context spreads form concerns everywhere
Bookings list Reducer + context + localStorage Needed by three screens and should persist
Seats left Computed by seatsLeftFor Storing it duplicates data and can disagree
Submit status Local state in the page Only the page needs submitting
Booking id for confirmation Router state A query parameter would make it shareable and guessable

Worked example: "Not enough seats" race

Problem. Two tabs show the same workshop with 3 seats left. In tab A the visitor books 2 seats. In tab B, still showing 3 seats left, the visitor tries to book 3 seats. What should happen, and how do you implement it?

Step 1: identify the real rule. The rule is "never accept more seats than remain at the time of booking". The form's max attribute and displayed hint only reflect the page when it rendered; they can be stale.

Step 2: validate at the last moment. Re-check inside the submit handler, using the latest bookings, not the value captured when the form rendered. With a back end, the server must perform the check atomically and answer with an error such as "Only 1 seat left".

Step 3: listen for changes across tabs. localStorage fires a storage event in other tabs when it changes. A small effect can reload the bookings when the event arrives:

useEffect(() => {
  function onStorage(event) {
    if (event.key === 'harbor.bookings') dispatch({ type: 'reloaded', bookings: JSON.parse(event.newValue ?? '[]') });
  }
  window.addEventListener('storage', onStorage);
  return () => window.removeEventListener('storage', onStorage);
}, []);

(This also needs a reloaded case in the reducer.) The cleanup function removes the listener when the provider unmounts.

Step 4: show a clear failure. If the check fails, keep the form values, show "Only 1 seat is left. Change the number and try again", and focus the seats field.

Why not only the max attribute? Attributes are hints for honest users, not rules. Users can edit the DOM, tabs can be stale, and the back end must never trust the client. Why not clear the form on failure? Retyping name and email on every error is the quickest way to lose a customer.

Common pitfalls and exam tips

  • Missing preventDefault. The page reloads and all state is lost.
  • Placeholder as label. The placeholder disappears when typing and is not a reliable accessible name; use a <label>.
  • Number inputs return strings. values.seats is "2"; convert with Number and validate integers.
  • State updates after unmount. Do not set state after navigating away.
  • Mutating state in a reducer. state.bookings.push(...) breaks React's change detection; return new arrays.
  • Context value recreated each render. Memoize it with useMemo when many consumers exist.
  • Storage without try/catch. Blocked storage should degrade gracefully.
  • Trusting client validation. It is a convenience, not security.
  • Duplicate submissions. Disable the button and guard in the handler.

Exam tip: a typical question shows a handler and asks why the form reloads or why state does not update. Look for the missing event.preventDefault(), a direct state mutation, or a stale closure. Another frequent question asks which construct shares data between distant components: context with a reducer.

Mini case study: the gym class signup

A gym signup form has name, phone, and class choice. Users report: pressing Enter on the phone field submits an empty form and shows nothing; screen reader users cannot tell which field failed; after a network error the form is cleared; and sometimes the member gets two confirmation emails.

Task. Identify the cause of each problem and propose the fix.

Solution.

  1. Enter submits an empty form with no message. Validation runs only in a click handler on the button. Move it into the form's onSubmit so Enter and click share the path, and show messages for empty fields.
  2. Screen readers cannot tell which field failed. Errors are colored text with no association. Add aria-invalid and aria-describedby linking each input to its message, and move focus to the first invalid field.
  3. Network error clears the form. The submit handler resets state before the request completes. Reset only after success; keep values on failure and show a retry message in a role="alert" region.
  4. Two emails. The button stays enabled during the request, and impatient users click twice. Track submitting, disable the button, and ignore submits while in progress. The server should also use an idempotency key so that a repeated request does not create a second record.

Key terms

  • Controlled input: an input whose value comes from React state.
  • Pure function: same input gives the same output with no side effects.
  • Reducer: a function (state, action) => newState describing transitions.
  • Context: a way to share values without passing props through every level.
  • Lazy initializer: a function passed to useReducer/useState that runs once.
  • aria-describedby: links an element to descriptive text such as an error.
  • Idempotency: repeating an operation does not change the result beyond the first time.
  • Derived state: a value computed from other state rather than stored.

Summary checklist

  • Every input has a visible label and an appropriate autoComplete value.
  • Validation is a pure function with actionable messages.
  • Errors are linked to fields, summarized in an alert, and focus moves to the first invalid field.
  • Bookings use a reducer, context, and defensive localStorage.
  • Seats left is computed in one function and used everywhere.
  • Submit shows loading, prevents duplicates, and shows a retry-able error.
  • Navigation to confirmation uses replace, and direct visits are handled.
  • Focus moves to the confirmation heading.

Official sources