Day 2 Introduction

Day 2 — Forms, Validation & API Calls

Day 2 covers controlled and uncontrolled form inputs, building a useForm custom hook with Zod validation, making API calls from within forms using AbortController, and completing the Movie Detail page. By the end trainees will have a fully functional app.

Before starting Day 2Confirm the Day 1 app is working: trending movies display, search with debounce works, watchlist and favourites persist across refresh, and Google sign-in functions. Fix anything broken before moving on.
Code conventionEvery step shows the complete file. New or changed lines are highlighted with a green left bar. Unchanged lines are dimmer so you can scan to what is new.

Day 2 · Chapter 7

Controlled vs Uncontrolled Components

Before building any form we establish the two ways React can manage inputs. Every form decision flows from understanding this distinction.

STEP 1Concept — who owns the value

Write this question on the whiteboard: "Where does the input's current value live?"

ControlledUncontrolled
Value lives inReact stateThe DOM
Read value viaState variable — any timeref.current.value — usually at submit
Validate whenEvery keystroke (onChange)Submit time only
Pre-populateSet initial statedefaultValue prop
ResetSet state to empty stringSet ref.current.value = ''
Best forMost forms — validation, formatting, dynamic fieldsFile inputs (required), third-party rich-text editors
STEP 2Controlled input — live demo
Goal

Show a controlled input. React state drives the displayed value — every keystroke updates state, and the input re-renders with the new value from state.

Scratch component — type this in a temporary file or ProfilePage for the demo
function ControlledDemo() {
  const [name, setName] = useState('');
  return (
    <div>
      <input
        type="text"
        value={name}                              // React controls what is displayed
        onChange={e => setName(e.target.value)}   // state updated on every keystroke
        placeholder="Type your name"
        className="bg-gray-800 border border-gray-700 rounded px-3 py-2"
      />
      <p>You typed: {name}</p>      {/* can read value at any time */}
      <p>Length: {name.length}</p>  {/* can derive other values from it */}
    </div>
  );
}
Every line explained
LineWhat it does
value={name}The input always displays whatever name state contains. React is the single source of truth. User types "a" → React state updates → re-render → input shows "a" from state, not the DOM
onChange={e => setName(e.target.value)}Fires on every keystroke. e.target.value is the current string. We push it into state, triggering a re-render with the updated value
{name} in paragraphCan read the value anywhere in the component at any time, without querying the DOM. This is the key advantage over uncontrolled
Observe
Demo — character filteringType in the field — the paragraph updates live. Then change onChange to e => setName(e.target.value.replace(/[0-9]/g, '')) — numbers are instantly blocked. Impossible with uncontrolled inputs. ✅
Q & A
Why does React need to re-render just to show a typed character?
In a controlled input the DOM defers to React state. You must have onChange — without it React overrides the DOM back to the state value, making the input appear read-only. The re-render cycle is sub-millisecond; the user never perceives lag.
STEP 3Uncontrolled input — live demo
Goal

Show an uncontrolled input. The DOM owns the value. We read it with a ref only at submit time.

function UncontrolledDemo() {
  const nameRef  = useRef(null);
  const emailRef = useRef(null);

  const handleSubmit = () => {
    const name  = nameRef.current.value;    // read from DOM only at submit
    const email = emailRef.current.value;
    console.log('Submitted:', { name, email });
  };

  return (
    <div className="flex gap-2">
      <input ref={nameRef}  type="text"  defaultValue="" placeholder="Name"
        className="bg-gray-800 border border-gray-700 rounded px-3 py-2" />
      <input ref={emailRef} type="email" defaultValue="" placeholder="Email"
        className="bg-gray-800 border border-gray-700 rounded px-3 py-2" />
      <button onClick={handleSubmit} className="bg-brand text-white px-4 py-2 rounded">
        Submit
      </button>
    </div>
  );
}
Every line explained
LineWhat it does
useRef(null)Creates a mutable ref initialised to null. After the component mounts, React sets ref.current to the actual DOM element
ref={nameRef}Attaches the ref. After mount, nameRef.current is the real <input> DOM node
nameRef.current.valueReads the input's current value directly from the DOM at submit time only. No re-renders triggered
defaultValue=""Sets the initial value without controlling it. Unlike value, defaultValue only applies on mount — the DOM manages the value after that
No onChange handlerReact has no idea what the user typed until we explicitly read it with the ref
Q & A
When is uncontrolled actually required?
For <input type="file"> the value is read-only by the browser — you cannot set it programmatically. You must use a ref: const file = fileRef.current.files[0]. Also useful for integrating third-party DOM libraries that manage their own internal state.
STEP 4Common mistakes — show deliberately
// MISTAKE 1: value without onChange — input becomes read-only
// React warning: "You provided a value prop without an onChange handler"
<input value={name} />
// Fix:
<input value={name} onChange={e => setName(e.target.value)} />

// MISTAKE 2: mixing value and defaultValue on the same input
// React warning: "Use either the defaultValue or value prop, not both"
<input value={name} defaultValue="Alice" />
// Fix: choose one

// MISTAKE 3: reading a ref before mount
function Wrong() {
  const ref = useRef(null);
  console.log(ref.current.value);  // crashes — ref.current is null here
  return <input ref={ref} />;
}
// Fix: read refs inside event handlers or useEffect (after mount)
The core ruleIf you set value, you must also set onChange. If you want uncontrolled, use only defaultValue. Never set both on the same input.

Day 2 · Chapter 8

Form Handling + Validation

We build a useForm custom hook from scratch — same idea as React Hook Form — so trainees understand what a form library manages. Then we add Zod schema validation and wire everything to the ProfilePage. We build in three passes: values only → add validation → add submission handling.

STEP 1useForm v1 — values only
Goal

Simplest possible version — just tracking field values in one state object. Test immediately before adding validation.

Code
src/hooks/useForm.js — version 1
import { useState, useCallback } from 'react';

export function useForm(initialValues, validate, onSubmit) {
  const [values, setValues] = useState(initialValues);

  const handleChange = useCallback((e) => {
    const { name, value, type, checked } = e.target;
    setValues(prev => ({
      ...prev,
      [name]: type === 'checkbox' ? checked : value,
    }));
  }, []);

  const fieldProps = (name) => ({
    name,
    value:    values[name] ?? '',
    onChange: handleChange,
  });

  return { values, handleChange, fieldProps };
}
Every line explained
LineWhat it does
useForm(initialValues, validate, onSubmit)All three parameters declared upfront even though only initialValues is used now. This avoids refactoring callers when we add validation in Step 2
useState(initialValues)Stores all field values in one object — e.g. { displayName: '', bio: '' }. One state call handles any number of fields
[name]: type === 'checkbox' ? checked : valueComputed property key — [name] uses the variable as the object key dynamically. One handler works for every field because each input's name attribute maps to its key in the values object
values[name] ?? ''Nullish coalescing — returns empty string if the value is null or undefined. Prevents the "uncontrolled to controlled" React warning
fieldProps(name)Generates the three props every controlled input needs. fieldProps('email') returns { name: 'email', value: values.email, onChange: handleChange }. Spread onto any input with {...fieldProps('email')}

Test immediately — replace ProfilePage with this minimal form:

src/pages/ProfilePage.jsx — test version
import { useForm } from '../hooks/useForm';

export default function ProfilePage() {
  const { values, fieldProps } = useForm({ name: '', bio: '' });
  return (
    <div className="max-w-lg mx-auto">
      <h1 className="text-2xl font-bold mb-6">Edit Profile</h1>
      <input {...fieldProps('name')} placeholder="Name"
        className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 mb-3 block" />
      <textarea {...fieldProps('bio')} placeholder="Bio" rows={3}
        className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 block" />
      <pre className="mt-4 text-xs text-gray-400 bg-gray-900 p-3 rounded">
        {JSON.stringify(values, null, 2)}
      </pre>
    </div>
  );
}
Observe
CheckpointGo to /profile (must be signed in). Type in both fields. The JSON block updates live on every keystroke. Both fields are controlled inputs. ✅
STEP 2Add touched + errors to useForm
Why "touched"
The UX problemShowing all validation errors immediately looks broken before the user has started. "Touched" means the user visited a field and left it (onBlur). We only show a field's error after it is touched. On submit, we mark all fields as touched so remaining errors all become visible at once.
Code
src/hooks/useForm.js — add touched + errors
import { useState, useCallback } from 'react';

export function useForm(initialValues, validate, onSubmit) {
  const [values,  setValues]  = useState(initialValues);
  const [errors,  setErrors]  = useState({});
  const [touched, setTouched] = useState({});

  const handleChange = useCallback((e) => {
    const { name, value, type, checked } = e.target;
    const newValues = { ...values, [name]: type === 'checkbox' ? checked : value };
    setValues(newValues);
    if (touched[name] && validate) {
      const errs = validate(newValues);
      setErrors(prev => ({ ...prev, [name]: errs[name] }));
    }
  }, [values, touched, validate]);

  const handleBlur = useCallback((e) => {
    const { name } = e.target;
    setTouched(prev => ({ ...prev, [name]: true }));
    if (validate) {
      const errs = validate(values);
      setErrors(prev => ({ ...prev, [name]: errs[name] }));
    }
  }, [values, validate]);

  const fieldProps = (name) => ({
    name,
    value:    values[name] ?? '',
    onChange: handleChange,
    onBlur:   handleBlur,
  });

  return { values, errors, touched, handleChange, handleBlur, fieldProps };
}
Every line explained
LineWhat it does
useState({}) for errorsObject mapping field names to error messages: { displayName: 'Too short', bio: undefined }. Undefined means no error for that field
useState({}) for touchedTracks which fields the user has visited: { displayName: true, bio: false }. Only true fields show their errors
if (touched[name] && validate)In handleChange: only re-validate this field if the user has already touched it. Updates only this field's error, leaving others unchanged
handleBlur fires onThe moment the user leaves a field (Tab or click elsewhere). Marks the field as touched and shows its error for the first time
onBlur: handleBlur in fieldPropsEvery field gets an onBlur handler automatically via fieldProps. Developers don't need to remember to add it manually to each input
STEP 3Zod schema + zodValidate adapter
Goal

A Zod schema for the profile form and a converter that turns Zod errors into the { fieldName: message } shape useForm expects.

Code
src/lib/schemas.js — full file
import { z } from 'zod';

export const profileSchema = z.object({
  displayName: z.string().min(2, 'Name must be at least 2 characters').max(50, 'Name too long'),
  bio:         z.string().max(200, 'Bio must be 200 characters or less').optional(),
  website:     z.string().url('Must be a valid URL — include https://').optional().or(z.literal('')),
  favouriteGenre: z.string().min(1, 'Please select a genre'),
});

// Converts Zod errors into { fieldName: 'message' } shape that useForm expects
export function zodValidate(schema) {
  return (values) => {
    const result = schema.safeParse(values);
    if (result.success) return {};
    return result.error.issues.reduce((acc, issue) => {
      const key = issue.path[0];
      if (key && !acc[key]) acc[key] = issue.message;
      return acc;
    }, {});
  };
}
Every line explained
LineWhat it does
z.object({ ... })Declares a schema for an object. Each key maps to a validator chain
z.string().min(2, 'message')Field must be a string with at least 2 characters. The second argument is the error message shown to the user
.optional()Makes the field not required — empty string or undefined passes
.url().optional().or(z.literal(''))A URL if provided, but also accepts empty string. Without .or(z.literal('')), an empty website field fails URL validation
schema.safeParse(values)Validates without throwing. Returns { success: true } or { success: false, error }
result.error.issues.reduce(...)Converts Zod's array of issue objects into our flat { fieldName: message } shape
!acc[key]Only uses the first error per field — user sees the most important one first
zodValidate returns a functionAdapter pattern — takes a schema and returns a (values) => errors function in the shape useForm expects
STEP 4Add handleSubmit to useForm
Goal

Add submission handling — mark all fields as touched, validate everything, call onSubmit only if valid, track submitting / submitError / submitSuccess states.

Code
src/hooks/useForm.js — full final version
import { useState, useCallback } from 'react';

export function useForm(initialValues, validate, onSubmit) {
  const [values,        setValues]        = useState(initialValues);
  const [errors,        setErrors]        = useState({});
  const [touched,       setTouched]       = useState({});
  const [submitting,    setSubmitting]    = useState(false);
  const [submitError,   setSubmitError]   = useState(null);
  const [submitSuccess, setSubmitSuccess] = useState(false);

  const handleChange = useCallback((e) => {
    const { name, value, type, checked } = e.target;
    const newValues = { ...values, [name]: type === 'checkbox' ? checked : value };
    setValues(newValues);
    if (touched[name] && validate) {
      const errs = validate(newValues);
      setErrors(prev => ({ ...prev, [name]: errs[name] }));
    }
  }, [values, touched, validate]);

  const handleBlur = useCallback((e) => {
    const { name } = e.target;
    setTouched(prev => ({ ...prev, [name]: true }));
    if (validate) {
      const errs = validate(values);
      setErrors(prev => ({ ...prev, [name]: errs[name] }));
    }
  }, [values, validate]);

  // Curried: handleSubmit(mySubmitFn) returns an async event handler
  // Usage in JSX: <form onSubmit={handleSubmit(onSubmit)}>
  const handleSubmit = useCallback((submitFn) => async (e) => {
    e.preventDefault();
    setSubmitError(null);
    setSubmitSuccess(false);

    // Mark ALL fields as touched so all errors become visible
    const allTouched = Object.keys(values).reduce((acc, k) => ({ ...acc, [k]: true }), {});
    setTouched(allTouched);

    const errs = validate ? validate(values) : {};
    setErrors(errs);
    if (Object.values(errs).some(Boolean)) return; // has errors — stop

    setSubmitting(true);
    try {
      await submitFn(values);
      setSubmitSuccess(true);
    } catch (err) {
      setSubmitError(err.message);
    } finally {
      setSubmitting(false);
    }
  }, [values, validate]);

  const reset = useCallback(() => {
    setValues(initialValues);
    setErrors({}); setTouched({});
    setSubmitError(null); setSubmitSuccess(false);
  }, [initialValues]);

  const fieldProps = (name) => ({
    name, value: values[name] ?? '', onChange: handleChange, onBlur: handleBlur,
  });

  return {
    values, errors, touched, submitting, submitError, submitSuccess,
    handleChange, handleBlur, handleSubmit, reset, fieldProps,
  };
}
Every line explained
LineWhat it does
handleSubmit = (submitFn) => async (e) => { }A curried function. handleSubmit takes the submit callback and returns an async event handler. Lets you write <form onSubmit={handleSubmit(onSubmit)}> while the hook manages loading/error/success
e.preventDefault()Stops the browser's default form submission (full page reload). Without this the page refreshes and all React state is lost
Object.keys(values).reduce(...)Builds { displayName: true, bio: true, ... }. Marking all fields as touched means all remaining errors become visible when the user clicks submit
Object.values(errs).some(Boolean)Returns true if any error message exists. If so, return early without calling the submit function
setSubmitting(true)Disables the submit button during the async call (preventing double-submit) and enables the "Saving…" loading state
try / catch / finallytry: await the submit. catch: set submitError so UI shows the error. finally: always stop the spinner, even if submit failed
reset()Resets all form state back to initialValues. Useful for "Write another review" after success
Q & A
Why is handleSubmit curried?
The curried pattern lets the component pass in its own async submit function: handleSubmit(myApiCall). The API call logic stays in the component (it knows the endpoint and payload) while the hook manages all loading/error/success state. If the submit call were inside the hook, the hook would need to know about the API, breaking separation of concerns.
Why e.preventDefault() if we are using React and not traditional HTML form submit?
The <form> element still has default browser behaviour — on submit it sends a GET or POST to the current URL, causing a full page reload. Even in a SPA we use <form> for accessibility (Enter key submits, screen readers understand form structure), so we must prevent the default.
STEP 5Build the full ProfilePage form
Goal

Wire useForm + Zod to a complete form with text, textarea, URL, and select inputs.

Code
src/pages/ProfilePage.jsx — full file
import { useCallback } from 'react';
import { useAuth }                    from '../context/AuthContext';
import { useForm }                    from '../hooks/useForm';
import { profileSchema, zodValidate } from '../lib/schemas';

// At module level — stable reference, never recreated on each render
const validate = zodValidate(profileSchema);

const GENRES = [
  'Action', 'Adventure', 'Animation', 'Comedy', 'Crime',
  'Drama', 'Fantasy', 'Horror', 'Romance', 'Sci-Fi', 'Thriller',
];

export default function ProfilePage() {
  const { user } = useAuth();

  const onSubmit = useCallback(async (values) => {
    await new Promise(r => setTimeout(r, 1000)); // simulate API call
    console.log('Profile saved:', values);
  }, []);

  const {
    values, errors, touched, submitting, submitError, submitSuccess,
    handleSubmit, fieldProps,
  } = useForm(
    { displayName: user?.displayName || '', bio: '', website: '', favouriteGenre: '' },
    validate,
    onSubmit
  );

  if (!user) return (
    <p className="text-center py-20 text-gray-400">Please sign in to edit your profile.</p>
  );

  return (
    <div className="max-w-lg mx-auto">
      <h1 className="text-2xl font-bold mb-6">Edit Profile</h1>
      <form onSubmit={handleSubmit(onSubmit)} className="space-y-5" noValidate>

        <Field label="Display Name" error={touched.displayName && errors.displayName}>
          <input {...fieldProps('displayName')} type="text" placeholder="Your name"
            className={inputCls(touched.displayName && errors.displayName)} />
        </Field>

        <Field label="Bio" error={touched.bio && errors.bio}>
          <textarea {...fieldProps('bio')} rows={3} placeholder="Tell us about yourself…"
            className={inputCls(touched.bio && errors.bio)} />
          <span className="text-xs text-gray-500">{values.bio?.length || 0}/200</span>
        </Field>

        <Field label="Website" error={touched.website && errors.website}>
          <input {...fieldProps('website')} type="url" placeholder="https://yoursite.com"
            className={inputCls(touched.website && errors.website)} />
        </Field>

        <Field label="Favourite Genre" error={touched.favouriteGenre && errors.favouriteGenre}>
          <select {...fieldProps('favouriteGenre')}
            className={inputCls(touched.favouriteGenre && errors.favouriteGenre)}>
            <option value="">— Select a genre —</option>
            {GENRES.map(g => <option key={g} value={g}>{g}</option>)}
          </select>
        </Field>

        {submitError   && <p className="text-red-400 text-sm">{submitError}</p>}
        {submitSuccess && <p className="text-green-400 text-sm">Profile saved! ✓</p>}

        <button type="submit" disabled={submitting}
          className="w-full bg-brand hover:bg-brand-light disabled:opacity-50
                     text-white font-semibold py-2.5 rounded transition-colors">
          {submitting ? 'Saving…' : 'Save Profile'}
        </button>
      </form>
    </div>
  );
}

function Field({ label, error, children }) {
  return (
    <div>
      <label className="block text-sm font-medium text-gray-300 mb-1">{label}</label>
      {children}
      {error && <p className="text-red-400 text-xs mt-1">{error}</p>}
    </div>
  );
}

function inputCls(hasError) {
  return `w-full bg-gray-800 border rounded-lg px-3 py-2 text-sm placeholder-gray-500
    focus:outline-none focus:ring-2 transition-colors
    ${hasError ? 'border-red-500 focus:ring-red-500' : 'border-gray-700 focus:ring-brand'}`;
}
Every line explained
LineWhat it does
const validate = zodValidate(profileSchema) at module levelDefined outside the component — stable reference. If inside the component it would be recreated every render, affecting useCallback dependencies in useForm
user?.displayName || ''Pre-populates the name field with the Google display name. ?. safely handles null. || '' provides an empty fallback
noValidate on formDisables the browser's built-in HTML5 validation (native red borders, tooltip popups). We use our own validation UI instead
handleSubmit(onSubmit)Calls the curried handleSubmit with the submit callback. Returns an async event handler. This is the curried pattern from Step 4
touched.displayName && errors.displayNameShort-circuit — only shows the error if the field has been touched AND has an error. Passing false or undefined to Field's error prop renders nothing
Field componentLocal helper wrapping a label, the input child, and the error message. Avoids repeating the three-element structure for every field
inputCls(hasError)Returns a className string. Border and focus ring change to red on error, brand colour when valid
disabled={submitting}Prevents double-submission while the async call is in progress
Q & A
When should I use React Hook Form instead of this custom useForm?
React Hook Form uses uncontrolled inputs internally — no re-render on every keystroke — giving much better performance on large forms. It also handles arrays of dynamic fields elegantly and has excellent TypeScript support. Our useForm is perfect for learning and for most forms under 15 fields. In production, React Hook Form + Zod is the current best-practice combination.
How do I handle a checkbox with this useForm?
The handleChange in our hook already checks e.target.type === 'checkbox' and uses e.target.checked instead of e.target.value. A checkbox works with {...fieldProps('rememberMe')} — no extra code. Just ensure the initial value in initialValues is a boolean, not a string.
STEP 6Test validation behaviour
Test each scenario live with trainees
  1. Click into Display Name and immediately press Tab → error: "Name must be at least 2 characters"
  2. Type a valid name → error disappears as you type (field is touched so live validation runs)
  3. Type not-a-url in Website and tab out → "Must be a valid URL" error appears
  4. Click "Save Profile" with Favourite Genre still empty → ALL remaining errors appear at once (allTouched)
  5. Fill all valid data and submit → "Saving…" for 1 second, then "Profile saved! ✓" ✅

Day 2 · Chapter 9

API Calls in Forms

We build a movie review form that makes an actual POST request. This covers the three error scenarios every form must handle and how to cancel in-flight requests with AbortController.

STEP 1Concept — the three error scenarios

Write this on the whiteboard before writing any code:

ScenarioHow to detectWhat to show
Network error (offline, DNS)fetch throws a TypeError"Network error — check your connection"
Server error (4xx / 5xx)response.ok === falseThe server's error message
Request cancelled by userr.name === 'AbortError'Silently ignore — do nothing
fetch does NOT throw on 404 or 500fetch only rejects its Promise when there is a network-level failure — the request could not be sent at all. A 404 or 500 is a valid HTTP response — the Promise resolves. You must check response.ok manually. This surprises developers coming from Axios, which throws on non-2xx by default.
STEP 2ReviewForm — skeleton with useForm
Goal

Build the complete form structure — star rating and review textarea — using useForm. The actual API call is added in Step 3.

Code
src/components/ReviewForm.jsx — full file (skeleton)
import { useCallback } from 'react';
import { useAuth }     from '../context/AuthContext';
import { useForm }     from '../hooks/useForm';
import { z }           from 'zod';
import { zodValidate } from '../lib/schemas';

const reviewSchema = z.object({
  rating:  z.string().refine(v => ['1','2','3','4','5'].includes(v), 'Select a rating'),
  content: z.string().min(20, 'Review must be at least 20 characters').max(1000),
});

const validate = zodValidate(reviewSchema);

export default function ReviewForm({ movieId, movieTitle }) {
  const { user } = useAuth();

  const onSubmit = useCallback(async (values) => {
    console.log('Submitting:', values); // placeholder — API call added in Step 3
  }, []);

  const {
    errors, touched, submitting, submitSuccess,
    handleSubmit, fieldProps, reset,
  } = useForm({ rating: '', content: '' }, validate, onSubmit);

  if (!user) return <p className="text-gray-400 text-sm">Sign in to leave a review.</p>;

  if (submitSuccess) {
    return (
      <div className="bg-green-900/30 border border-green-700 rounded-lg p-4">
        <p className="text-green-400 font-medium">Review submitted! ✓</p>
        <button onClick={reset} className="text-sm text-gray-400 underline mt-2">
          Write another
        </button>
      </div>
    );
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
      <h3 className="font-semibold">Write a Review</h3>

      {/* Star rating — controlled radio group */}
      <div>
        <label className="block text-sm text-gray-300 mb-2">Rating</label>
        <div className="flex gap-3">
          {[1, 2, 3, 4, 5].map(n => {
            const fp = fieldProps('rating');
            return (
              <label key={n} className="cursor-pointer">
                <input type="radio" name="rating" value={String(n)}
                  checked={fp.value === String(n)}
                  onChange={fp.onChange} onBlur={fp.onBlur}
                  className="sr-only" />
                <span className={`text-2xl transition-colors
                  ${Number(fp.value) >= n ? 'text-yellow-400' : 'text-gray-600'}`}
                >★</span>
              </label>
            );
          })}
        </div>
        {touched.rating && errors.rating &&
          <p className="text-red-400 text-xs mt-1">{errors.rating}</p>}
      </div>

      <div>
        <textarea {...fieldProps('content')} rows={4}
          placeholder={`What did you think of ${movieTitle}?`}
          className={`w-full bg-gray-800 border rounded-lg px-3 py-2 text-sm
            placeholder-gray-500 focus:outline-none focus:ring-2 resize-none
            ${touched.content && errors.content
              ? 'border-red-500 focus:ring-red-500' : 'border-gray-700 focus:ring-brand'}`} />
        {touched.content && errors.content &&
          <p className="text-red-400 text-xs mt-1">{errors.content}</p>}
      </div>

      <button type="submit" disabled={submitting}
        className="bg-brand hover:bg-brand-light disabled:opacity-50
                   text-white font-semibold px-6 py-2 rounded transition-colors">
        {submitting ? 'Submitting…' : 'Submit Review'}
      </button>
    </form>
  );
}
Every line explained
LineWhat it does
z.string().refine(v => [...].includes(v), 'msg')Zod's refine runs a custom validator. Returns false if the value is not one of the five valid star options
const fp = fieldProps('rating')Gets the field props for the rating. We spread props individually on the radio input because radio inputs use checked not value for selection state
checked={fp.value === String(n)}Each star's checked state is driven by whether the current rating value matches this star's number — this is what makes the radio group controlled
className="sr-only"Screen-reader only — hides the native radio button visually but keeps it accessible. We style the star spans instead
Number(fp.value) >= nIf selected rating is 3, stars 1, 2, and 3 turn yellow. Stars 4 and 5 stay grey. Creates the visual fill effect
if (submitSuccess) return ...After success, the form is replaced with a success message and a "Write another" button that calls reset() to return to the form
STEP 3Add fetch + AbortController
Goal

Replace the console.log with a real fetch. Use useRef to hold the AbortController so we can cancel previous requests and cancel on unmount.

Code
src/components/ReviewForm.jsx — full file
import { useCallback, useRef, useEffect } from 'react';
import { useAuth }     from '../context/AuthContext';
import { useForm }     from '../hooks/useForm';
import { z }           from 'zod';
import { zodValidate } from '../lib/schemas';

const reviewSchema = z.object({
  rating:  z.string().refine(v => ['1','2','3','4','5'].includes(v), 'Select a rating'),
  content: z.string().min(20, 'Review must be at least 20 characters').max(1000),
});

const validate = zodValidate(reviewSchema);

export default function ReviewForm({ movieId, movieTitle }) {
  const { user } = useAuth();
  const controllerRef = useRef(null);

  useEffect(() => {
    return () => controllerRef.current?.abort(); // cancel on unmount
  }, []);

  const onSubmit = useCallback(async (values) => {
    controllerRef.current?.abort();               // cancel previous if still running
    controllerRef.current = new AbortController();

    const response = await fetch('/api/reviews', {
      method:  'POST',
      signal:  controllerRef.current.signal,
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        movieId,
        userId:    user.uid,
        rating:    Number(values.rating),
        content:   values.content,
        createdAt: new Date().toISOString(),
      }),
    });

    if (!response.ok) {
      const body = await response.json().catch(() => ({}));
      throw new Error(body.message || `Server error: ${response.status}`);
    }
  }, [movieId, user]);

  const {
    errors, touched, submitting, submitError, submitSuccess,
    handleSubmit, fieldProps, reset,
  } = useForm({ rating: '', content: '' }, validate, onSubmit);

  if (!user) return <p className="text-gray-400 text-sm">Sign in to leave a review.</p>;

  if (submitSuccess) {
    return (
      <div className="bg-green-900/30 border border-green-700 rounded-lg p-4">
        <p className="text-green-400 font-medium">Review submitted! ✓</p>
        <button onClick={reset} className="text-sm text-gray-400 underline mt-2">Write another</button>
      </div>
    );
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
      <h3 className="font-semibold">Write a Review</h3>
      <div>
        <label className="block text-sm text-gray-300 mb-2">Rating</label>
        <div className="flex gap-3">
          {[1, 2, 3, 4, 5].map(n => {
            const fp = fieldProps('rating');
            return (
              <label key={n} className="cursor-pointer">
                <input type="radio" name="rating" value={String(n)}
                  checked={fp.value === String(n)}
                  onChange={fp.onChange} onBlur={fp.onBlur} className="sr-only" />
                <span className={`text-2xl ${Number(fp.value) >= n ? 'text-yellow-400' : 'text-gray-600'}`}>★</span>
              </label>
            );
          })}
        </div>
        {touched.rating && errors.rating && <p className="text-red-400 text-xs mt-1">{errors.rating}</p>}
      </div>
      <div>
        <textarea {...fieldProps('content')} rows={4}
          placeholder={`What did you think of ${movieTitle}?`}
          className={`w-full bg-gray-800 border rounded-lg px-3 py-2 text-sm
            placeholder-gray-500 focus:outline-none focus:ring-2 resize-none
            ${touched.content && errors.content ? 'border-red-500 focus:ring-red-500' : 'border-gray-700 focus:ring-brand'}`} />
        {touched.content && errors.content && <p className="text-red-400 text-xs mt-1">{errors.content}</p>}
      </div>
      {submitError && (
        <div className="bg-red-900/30 border border-red-700 rounded p-3 text-red-400 text-sm">
          {submitError}
        </div>
      )}
      <button type="submit" disabled={submitting}
        className="bg-brand hover:bg-brand-light disabled:opacity-50 text-white font-semibold px-6 py-2 rounded">
        {submitting ? 'Submitting…' : 'Submit Review'}
      </button>
    </form>
  );
}
Every line explained
LineWhat it does
const controllerRef = useRef(null)We use a ref (not state) because changing the controller should not trigger a re-render. The AbortController is internal plumbing — it doesn't affect what is displayed
return () => controllerRef.current?.abort()Cleanup on unmount. If the user navigates away while a review is submitting, the in-flight request is cancelled. The ?. optional chaining is safe when the ref is null
controllerRef.current?.abort()Cancels any previous in-flight request before starting a new one
controllerRef.current = new AbortController()Creates a fresh controller for this submission
signal: controllerRef.current.signalLinks this specific fetch to the controller. Calling abort() cancels this network request at the browser level
if (!response.ok) { ... throw }The critical check. fetch resolves even for 4xx/5xx. We check ok manually, try to read the JSON body for an error message, then throw so useForm's catch block sets submitError
response.json().catch(() => ({}))If the error response has no JSON body, .json() would throw. The .catch returns an empty object as a fallback
About /api/reviewsThis is a fictional endpoint. In a real app replace it with your actual API URL or a Firestore write. During training the fetch will fail — this is fine and demonstrates the error handling path. The submitError message will appear in the UI.
STEP 4Test error and success states
Test each state
  1. Submit without selecting a star → "Select a rating" error appears
  2. Submit with a review shorter than 20 chars → length error appears
  3. Fill valid data and submit → "Submitting…" then server error appears (fictional endpoint). Confirms error handling works
  4. To test success state: temporarily change onSubmit to await new Promise(r => setTimeout(r, 1000)) and submit → success message and "Write another" reset button appear ✅
Q & A
Why use a ref for AbortController instead of state?
Changing a ref does not cause a re-render. The AbortController is internal plumbing — it does not affect what the user sees. Putting it in state would trigger unnecessary re-renders and potentially affect useCallback dependencies.
Can I use async/await directly inside useEffect?
No — useEffect must not be declared async because it expects the return value to be either nothing or a cleanup function, not a Promise. Define an async function inside the effect and call it immediately. This is the pattern used in our useMovies hook.

Day 2 · Chapter 10

Movie Detail Page

The final chapter brings everything together — useParams, a useEffect-driven API call, watchlist context, a cast row, and the ReviewForm from Chapter 9.

STEP 1useParams + fetch movie details
Goal

Read the movie ID from the URL and fetch full movie details. Start with a raw JSON dump — add the layout in Step 2.

Code
src/pages/MovieDetailPage.jsx — full file
import { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { HEADERS, imgUrl } from '../lib/tmdb';

const BASE = 'https://api.themoviedb.org/3';

export default function MovieDetailPage() {
  const { id } = useParams();

  const [movie,   setMovie]   = useState(null);
  const [loading, setLoading] = useState(true);
  const [error,   setError]   = useState(null);

  useEffect(() => {
    const controller = new AbortController();
    setLoading(true);
    fetch(`${BASE}/movie/${id}?append_to_response=credits,videos`, {
      signal:  controller.signal,
      headers: HEADERS,
    })
      .then(r => r.json())
      .then(data => { setMovie(data); setLoading(false); })
      .catch(err => {
        if (err.name === 'AbortError') return;
        setError(err.message); setLoading(false);
      });
    return () => controller.abort();
  }, [id]);

  if (loading) return <div className="text-center py-20 text-gray-400">Loading…</div>;
  if (error)   return <div className="text-center py-20 text-red-400">{error}</div>;
  if (!movie)  return null;

  return (
    <pre className="text-xs text-gray-400 overflow-auto">
      {JSON.stringify(movie, null, 2)}
    </pre>
  );
}
Every line explained
LineWhat it does
const { id } = useParams()React Router extracts the :id URL segment. For /movie/550, id is the string "550"
append_to_response=credits,videosTMDB bundles multiple requests into one. Fetches cast/crew and trailer data in the same API call instead of three separate requests
[id] dependency arrayIf the user navigates from /movie/123 to /movie/456 without unmounting the component, the effect re-runs with the new id. Without this the page keeps showing the first movie's data
Early returns after hooksReact requires hooks to always run in the same order. Early returns must come after all hook calls, never before
Raw JSON dumpShows the full API response so trainees can see what fields are available: point out genres, runtime, overview, credits.cast, backdrop_path
Observe
CheckpointClick any movie card from the home page. The detail page shows raw JSON. Explore — point out credits.cast[0] and backdrop_path. ✅
STEP 2Backdrop, poster, and movie info layout
Goal

Replace the raw JSON with a proper layout — backdrop image, poster overlapping it, title, genres, rating, and overview.

Code
src/pages/MovieDetailPage.jsx — full file
import { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { HEADERS, imgUrl } from '../lib/tmdb';

const BASE = 'https://api.themoviedb.org/3';

export default function MovieDetailPage() {
  const { id } = useParams();
  const [movie,   setMovie]   = useState(null);
  const [loading, setLoading] = useState(true);
  const [error,   setError]   = useState(null);

  useEffect(() => {
    const controller = new AbortController();
    setLoading(true);
    fetch(`${BASE}/movie/${id}?append_to_response=credits,videos`, {
      signal: controller.signal, headers: HEADERS,
    })
      .then(r => r.json())
      .then(data => { setMovie(data); setLoading(false); })
      .catch(err => { if (err.name === 'AbortError') return; setError(err.message); setLoading(false); });
    return () => controller.abort();
  }, [id]);

  if (loading) return <div className="text-center py-20 text-gray-400">Loading…</div>;
  if (error)   return <div className="text-center py-20 text-red-400">{error}</div>;
  if (!movie)  return null;

  return (
    <div>
      <div className="relative h-64 md:h-96 -mx-4 mb-8 bg-cover bg-center"
        style={{ backgroundImage: `url(${imgUrl(movie.backdrop_path, 'w1280')})` }}>
        <div className="absolute inset-0 bg-gradient-to-t from-gray-950 to-transparent" />
      </div>

      <div className="flex flex-col md:flex-row gap-8">
        <img src={imgUrl(movie.poster_path)} alt={movie.title}
          className="w-48 rounded-lg shadow-2xl -mt-24 relative z-10 shrink-0" />

        <div className="flex-1">
          <h1 className="text-3xl font-bold">{movie.title}</h1>
          <p className="text-gray-400 mt-1">
            {movie.release_date?.slice(0, 4)} · {movie.runtime} min ·{' '}
            {movie.genres?.map(g => g.name).join(', ')}
          </p>
          <p className="text-yellow-400 mt-2">
            ⭐ {movie.vote_average?.toFixed(1)}
            <span className="text-gray-500 text-sm ml-2">({movie.vote_count?.toLocaleString()} votes)</span>
          </p>
          <p className="mt-4 text-gray-300 leading-relaxed max-w-prose">{movie.overview}</p>
        </div>
      </div>
    </div>
  );
}
Every line explained
LineWhat it does
style={{ backgroundImage: `url(...)` }}Inline style for the backdrop. Tailwind cannot generate dynamic CSS values at runtime, so we use a style prop. w1280 is the highest-quality backdrop size
-mx-4Extends the backdrop full-width, bleeding past the page's px-4 padding. Negative margin is the common pattern for full-bleed sections inside a padded container
bg-gradient-to-t from-gray-950 to-transparentGradient overlay from the bottom (dark) to the top (transparent). Makes the backdrop fade naturally into the dark page below it
-mt-24 relative z-10The poster pops up over the backdrop with a negative top margin of 6rem. z-10 ensures it renders above the gradient overlay
movie.genres?.map(g => g.name).join(', ')Genres is an array of { id, name } objects. We extract names and join into "Action, Crime, Drama"
max-w-proseLimits the overview text to ~65 characters wide — the ideal line length for comfortable reading
Observe
CheckpointMovie detail page shows the backdrop fading into the page, the poster overlapping the backdrop, and all movie info. ✅
STEP 3Add watchlist buttons and cast row
Code
src/pages/MovieDetailPage.jsx — full file
import { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { HEADERS, imgUrl } from '../lib/tmdb';
import { useWatchlist } from '../context/WatchlistContext';

const BASE = 'https://api.themoviedb.org/3';

export default function MovieDetailPage() {
  const { id } = useParams();
  const [movie,   setMovie]   = useState(null);
  const [loading, setLoading] = useState(true);
  const [error,   setError]   = useState(null);

  const {
    isInWatchlist,  addToWatchlist,  removeFromWatchlist,
    isInFavourites, addToFavourites, removeFromFavourites,
  } = useWatchlist();

  useEffect(() => {
    const controller = new AbortController();
    setLoading(true);
    fetch(`${BASE}/movie/${id}?append_to_response=credits,videos`, {
      signal: controller.signal, headers: HEADERS,
    })
      .then(r => r.json())
      .then(data => { setMovie(data); setLoading(false); })
      .catch(err => { if (err.name === 'AbortError') return; setError(err.message); setLoading(false); });
    return () => controller.abort();
  }, [id]);

  if (loading) return <div className="text-center py-20 text-gray-400">Loading…</div>;
  if (error)   return <div className="text-center py-20 text-red-400">{error}</div>;
  if (!movie)  return null;

  const inWatchlist  = isInWatchlist(movie.id);
  const inFavourites = isInFavourites(movie.id);

  return (
    <div>
      <div className="relative h-64 md:h-96 -mx-4 mb-8 bg-cover bg-center"
        style={{ backgroundImage: `url(${imgUrl(movie.backdrop_path, 'w1280')})` }}>
        <div className="absolute inset-0 bg-gradient-to-t from-gray-950 to-transparent" />
      </div>

      <div className="flex flex-col md:flex-row gap-8">
        <img src={imgUrl(movie.poster_path)} alt={movie.title}
          className="w-48 rounded-lg shadow-2xl -mt-24 relative z-10 shrink-0" />
        <div className="flex-1">
          <h1 className="text-3xl font-bold">{movie.title}</h1>
          <p className="text-gray-400 mt-1">
            {movie.release_date?.slice(0, 4)} · {movie.runtime} min ·{' '}
            {movie.genres?.map(g => g.name).join(', ')}
          </p>
          <p className="text-yellow-400 mt-2">
            ⭐ {movie.vote_average?.toFixed(1)}
            <span className="text-gray-500 text-sm ml-2">({movie.vote_count?.toLocaleString()} votes)</span>
          </p>
          <p className="mt-4 text-gray-300 leading-relaxed max-w-prose">{movie.overview}</p>

          <div className="flex gap-3 mt-6">
            <button
              onClick={() => inWatchlist ? removeFromWatchlist(movie.id) : addToWatchlist(movie)}
              className={`px-4 py-2 rounded font-medium transition-colors
                ${inWatchlist ? 'bg-brand text-white' : 'bg-gray-800 hover:bg-gray-700 text-gray-200'}`}
            >
              {inWatchlist ? '✓ In Watchlist' : '+ Add to Watchlist'}
            </button>
            <button
              onClick={() => inFavourites ? removeFromFavourites(movie.id) : addToFavourites(movie)}
              className={`px-4 py-2 rounded font-medium transition-colors
                ${inFavourites ? 'text-yellow-400 bg-yellow-400/10' : 'text-gray-400 bg-gray-800 hover:bg-gray-700'}`}
            >
              {inFavourites ? '♥ Favourited' : '♡ Add to Favourites'}
            </button>
          </div>
        </div>
      </div>

      {movie.credits?.cast?.length > 0 && (
        <section className="mt-10">
          <h2 className="text-xl font-semibold mb-4">Cast</h2>
          <div className="flex gap-4 overflow-x-auto pb-2">
            {movie.credits.cast.slice(0, 10).map(person => (
              <div key={person.id} className="shrink-0 w-24 text-center">
                <img src={imgUrl(person.profile_path, 'w185')} alt={person.name}
                  className="w-24 h-24 object-cover rounded-full bg-gray-800" />
                <p className="text-xs mt-2 font-medium truncate">{person.name}</p>
                <p className="text-xs text-gray-500 truncate">{person.character}</p>
              </div>
            ))}
          </div>
        </section>
      )}
    </div>
  );
}
Every line explained
LineWhat it does
useWatchlist()Reads WatchlistContext — works because MovieDetailPage is inside WatchlistProvider. Toggling the watchlist from the detail page will also update MovieCard on the home page because they share the same context state
const inWatchlist = isInWatchlist(movie.id)Derived boolean computed after loading completes when movie.id is available. Drives button text and colour
bg-yellow-400/10Tailwind's opacity modifier — bg-yellow-400 at 10% opacity. Creates a subtle yellow tint background when favourited
movie.credits?.cast?.length > 0Double optional chaining — safe if credits or cast is missing from the response
.slice(0, 10)Shows only the top 10 cast members. TMDB returns the full cast sorted by billing order — first 10 are the leads
overflow-x-auto pb-2Horizontal scroll for the cast strip. pb-2 adds bottom padding so the scrollbar does not overlap content
shrink-0 w-24Prevents each cast card from shrinking in the flex container. Fixed 6rem width for all cards
Observe
CheckpointWatchlist/favourites buttons appear on the detail page. Click "Add to Watchlist" — it turns red. Navigate back to home — that movie's card already shows "✓ Watchlist" (shared context). Cast strip scrolls horizontally. ✅
STEP 4Add ReviewForm to the detail page
Goal

Place the ReviewForm from Chapter 9 at the bottom of the detail page.

Code — add to MovieDetailPage.jsx
src/pages/MovieDetailPage.jsx — full file
import { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { HEADERS, imgUrl } from '../lib/tmdb';
import { useWatchlist } from '../context/WatchlistContext';
import ReviewForm from '../components/ReviewForm';

const BASE = 'https://api.themoviedb.org/3';

export default function MovieDetailPage() {
  const { id } = useParams();
  const [movie,   setMovie]   = useState(null);
  const [loading, setLoading] = useState(true);
  const [error,   setError]   = useState(null);
  const { isInWatchlist, addToWatchlist, removeFromWatchlist,
          isInFavourites, addToFavourites, removeFromFavourites } = useWatchlist();

  useEffect(() => {
    const controller = new AbortController();
    setLoading(true);
    fetch(`${BASE}/movie/${id}?append_to_response=credits,videos`, {
      signal: controller.signal, headers: HEADERS,
    })
      .then(r => r.json())
      .then(data => { setMovie(data); setLoading(false); })
      .catch(err => { if (err.name === 'AbortError') return; setError(err.message); setLoading(false); });
    return () => controller.abort();
  }, [id]);

  if (loading) return <div className="text-center py-20 text-gray-400">Loading…</div>;
  if (error)   return <div className="text-center py-20 text-red-400">{error}</div>;
  if (!movie)  return null;

  const inWatchlist  = isInWatchlist(movie.id);
  const inFavourites = isInFavourites(movie.id);

  return (
    <div>
      <div className="relative h-64 md:h-96 -mx-4 mb-8 bg-cover bg-center"
        style={{ backgroundImage: `url(${imgUrl(movie.backdrop_path, 'w1280')})` }}>
        <div className="absolute inset-0 bg-gradient-to-t from-gray-950 to-transparent" />
      </div>

      <div className="flex flex-col md:flex-row gap-8">
        <img src={imgUrl(movie.poster_path)} alt={movie.title}
          className="w-48 rounded-lg shadow-2xl -mt-24 relative z-10 shrink-0" />
        <div className="flex-1">
          <h1 className="text-3xl font-bold">{movie.title}</h1>
          <p className="text-gray-400 mt-1">
            {movie.release_date?.slice(0, 4)} · {movie.runtime} min ·{' '}
            {movie.genres?.map(g => g.name).join(', ')}
          </p>
          <p className="text-yellow-400 mt-2">⭐ {movie.vote_average?.toFixed(1)}
            <span className="text-gray-500 text-sm ml-2">({movie.vote_count?.toLocaleString()} votes)</span>
          </p>
          <p className="mt-4 text-gray-300 leading-relaxed max-w-prose">{movie.overview}</p>
          <div className="flex gap-3 mt-6">
            <button onClick={() => inWatchlist ? removeFromWatchlist(movie.id) : addToWatchlist(movie)}
              className={`px-4 py-2 rounded font-medium transition-colors
                ${inWatchlist ? 'bg-brand text-white' : 'bg-gray-800 hover:bg-gray-700 text-gray-200'}`}>
              {inWatchlist ? '✓ In Watchlist' : '+ Add to Watchlist'}
            </button>
            <button onClick={() => inFavourites ? removeFromFavourites(movie.id) : addToFavourites(movie)}
              className={`px-4 py-2 rounded font-medium transition-colors
                ${inFavourites ? 'text-yellow-400 bg-yellow-400/10' : 'text-gray-400 bg-gray-800 hover:bg-gray-700'}`}>
              {inFavourites ? '♥ Favourited' : '♡ Add to Favourites'}
            </button>
          </div>
        </div>
      </div>

      {movie.credits?.cast?.length > 0 && (
        <section className="mt-10">
          <h2 className="text-xl font-semibold mb-4">Cast</h2>
          <div className="flex gap-4 overflow-x-auto pb-2">
            {movie.credits.cast.slice(0, 10).map(person => (
              <div key={person.id} className="shrink-0 w-24 text-center">
                <img src={imgUrl(person.profile_path, 'w185')} alt={person.name}
                  className="w-24 h-24 object-cover rounded-full bg-gray-800" />
                <p className="text-xs mt-2 font-medium truncate">{person.name}</p>
                <p className="text-xs text-gray-500 truncate">{person.character}</p>
              </div>
            ))}
          </div>
        </section>
      )}

      <section className="mt-10 max-w-xl">
        <ReviewForm movieId={movie.id} movieTitle={movie.title} />
      </section>
    </div>
  );
}
Every line explained
LineWhat it does
import ReviewFormImports the component from Chapter 9. It handles its own form state, validation, and submission internally
movieId={movie.id} movieTitle={movie.title}The two props ReviewForm needs: the id for the API call payload and the title for the placeholder text. ReviewForm does not need the full movie object
max-w-xlConstrains the review form to a comfortable 36rem width — a form wider than this looks awkward
STEP 5Final checkpoint — run the complete app
Walk through the complete flow with trainees
  1. Sign in with Google — avatar appears in Navbar ← Chapter 2 (Day 1)
  2. Trending movies appear on the home page ← Chapter 3 (Day 1)
  3. Type in the search box — pause 400ms — results appear (check Network tab: one request) ← useDebounce
  4. Click a genre filter — grid narrows ← useMemo
  5. Click a sort button — movies reorder ← useMemo
  6. Add a movie to Watchlist — button turns red ← useReducer + WatchlistContext
  7. Click ♥ — heart turns yellow
  8. Navigate to /watchlist — both lists appear, sorted by most recently added ← useLocalStorage
  9. Refresh the page — watchlist and favourites still there ← localStorage persistence
  10. Click a movie card — detail page with backdrop, poster, cast row ← useParams, Chapter 10
  11. Add to Watchlist from the detail page — navigating back to home shows the card already marked ← shared WatchlistContext
  12. Go to /profile — trigger an error by tabbing out of Name immediately, fill valid data, submit — "Profile saved! ✓" ← useForm + Zod
  13. Scroll to Review section on any movie — fill stars and text, submit — server error appears (fictional endpoint), confirming error handling works ← AbortController
  14. Sign out — avatar gone, Profile link disappears

Every step maps to a concept introduced across the two training days. If trainees can walk through this list without prompts, the session was a success. ✅


Reference

Complete Final File Tree

cinetrack/ ├── .env ├── .gitignore ├── index.html ├── vite.config.js ├── package.json └── src/ ├── main.jsx ├── App.jsx ├── index.css ├── lib/ │ ├── firebase.js │ ├── tmdb.js │ └── schemas.js ← added Day 2, Ch 8 ├── context/ │ ├── AuthContext.jsx │ └── WatchlistContext.jsx ├── store/ │ ├── watchlistReducer.js │ └── actions.js ├── hooks/ │ ├── useDebounce.js │ ├── useLocalStorage.js │ ├── useMovies.js │ └── useForm.js ← added Day 2, Ch 8 ├── components/ │ ├── Navbar.jsx │ ├── MovieCard.jsx │ ├── SearchBar.jsx │ ├── GenreFilter.jsx │ └── ReviewForm.jsx ← added Day 2, Ch 9 └── pages/ ├── HomePage.jsx ├── MovieDetailPage.jsx ← completed Day 2, Ch 10 ├── WatchlistPage.jsx └── ProfilePage.jsx ← completed Day 2, Ch 8

Reference

Glossary

TermPlain-English definition
AbortControllerBrowser API for cancelling in-flight fetch requests. Consists of a controller with abort() and a signal passed to fetch.
Action creatorFunction returning an action object — prevents typos in type strings, enables editor autocomplete.
Controlled componentForm input whose value is driven by React state. Requires both value and onChange.
Curried functionA function that returns another function. handleSubmit(onSubmit) returns an async event handler — this is currying.
Custom hookJavaScript function starting with use that calls React hooks to encapsulate reusable stateful logic.
DebounceDelay reacting to a rapidly changing value until it stops changing for a set period.
dispatchFunction from useReducer that sends an action to the reducer, triggering a state update.
Pure functionGiven the same inputs always returns the same output with no side effects. Reducers must be pure.
ReducerPure function (state, action) → newState. Must return a new object or array, never mutate in place.
response.okBoolean on the fetch Response. True for 200–299. Must be checked manually — fetch does not throw on 4xx/5xx.
TouchedWhether a user has visited and left a form field (via onBlur). Controls when validation errors are shown.
Uncontrolled componentForm input whose value lives in the DOM. Read via a ref at submit time. Uses defaultValue instead of value.
useCallbackHook that memoizes a function reference between renders.
useMemoHook that memoizes a computed value between renders.
useParamsReact Router hook that reads URL parameters. In /movie/:id, useParams() returns { id: '550' }.
useReducerHook for managing complex state with a reducer function. Returns [state, dispatch].
ZodSchema validation library. Declares rules as composable schemas. safeParse validates without throwing.