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.
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.
Write this question on the whiteboard: "Where does the input's current value live?"
| Controlled | Uncontrolled | |
|---|---|---|
| Value lives in | React state | The DOM |
| Read value via | State variable — any time | ref.current.value — usually at submit |
| Validate when | Every keystroke (onChange) | Submit time only |
| Pre-populate | Set initial state | defaultValue prop |
| Reset | Set state to empty string | Set ref.current.value = '' |
| Best for | Most forms — validation, formatting, dynamic fields | File inputs (required), third-party rich-text editors |
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 demofunction 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
| Line | What 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 paragraph | Can read the value anywhere in the component at any time, without querying the DOM. This is the key advantage over uncontrolled |
e => setName(e.target.value.replace(/[0-9]/g, '')) — numbers are instantly blocked. Impossible with uncontrolled inputs. ✅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
| Line | What 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.value | Reads 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 handler | React has no idea what the user typed until we explicitly read it with the ref |
<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.// 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)
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.
Simplest possible version — just tracking field values in one state object. Test immediately before adding validation.
Codeimport { 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
| Line | What 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 : value | Computed 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:
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
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
| Line | What it does |
|---|---|
| useState({}) for errors | Object mapping field names to error messages: { displayName: 'Too short', bio: undefined }. Undefined means no error for that field |
| useState({}) for touched | Tracks 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 on | The 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 fieldProps | Every field gets an onBlur handler automatically via fieldProps. Developers don't need to remember to add it manually to each input |
A Zod schema for the profile form and a converter that turns Zod errors into the { fieldName: message } shape useForm expects.
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
| Line | What 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 function | Adapter pattern — takes a schema and returns a (values) => errors function in the shape useForm expects |
Add submission handling — mark all fields as touched, validate everything, call onSubmit only if valid, track submitting / submitError / submitSuccess states.
Codeimport { 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
| Line | What 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 / finally | try: 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 |
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.<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.Wire useForm + Zod to a complete form with text, textarea, URL, and select inputs.
Codeimport { 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
| Line | What it does |
|---|---|
| const validate = zodValidate(profileSchema) at module level | Defined 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 form | Disables 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.displayName | Short-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 component | Local 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 |
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.- Click into Display Name and immediately press Tab → error: "Name must be at least 2 characters"
- Type a valid name → error disappears as you type (field is touched so live validation runs)
- Type
not-a-urlin Website and tab out → "Must be a valid URL" error appears - Click "Save Profile" with Favourite Genre still empty → ALL remaining errors appear at once (allTouched)
- 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.
Write this on the whiteboard before writing any code:
| Scenario | How to detect | What to show |
|---|---|---|
| Network error (offline, DNS) | fetch throws a TypeError | "Network error — check your connection" |
| Server error (4xx / 5xx) | response.ok === false | The server's error message |
| Request cancelled by us | err.name === 'AbortError' | Silently ignore — do nothing |
fetch 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.Build the complete form structure — star rating and review textarea — using useForm. The actual API call is added in Step 3.
Codeimport { 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
| Line | What 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) >= n | If 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 |
Replace the console.log with a real fetch. Use useRef to hold the AbortController so we can cancel previous requests and cancel on unmount.
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
| Line | What 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.signal | Links 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 |
- Submit without selecting a star → "Select a rating" error appears
- Submit with a review shorter than 20 chars → length error appears
- Fill valid data and submit → "Submitting…" then server error appears (fictional endpoint). Confirms error handling works
- 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 ✅
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.
Read the movie ID from the URL and fetch full movie details. Start with a raw JSON dump — add the layout in Step 2.
Codeimport { 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
| Line | What it does |
|---|---|
| const { id } = useParams() | React Router extracts the :id URL segment. For /movie/550, id is the string "550" |
| append_to_response=credits,videos | TMDB bundles multiple requests into one. Fetches cast/crew and trailer data in the same API call instead of three separate requests |
| [id] dependency array | If 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 hooks | React requires hooks to always run in the same order. Early returns must come after all hook calls, never before |
| Raw JSON dump | Shows the full API response so trainees can see what fields are available: point out genres, runtime, overview, credits.cast, backdrop_path |
credits.cast[0] and backdrop_path. ✅Replace the raw JSON with a proper layout — backdrop image, poster overlapping it, title, genres, rating, and overview.
Codeimport { 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
| Line | What 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-4 | Extends 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-transparent | Gradient overlay from the bottom (dark) to the top (transparent). Makes the backdrop fade naturally into the dark page below it |
| -mt-24 relative z-10 | The 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-prose | Limits the overview text to ~65 characters wide — the ideal line length for comfortable reading |
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
| Line | What 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/10 | Tailwind's opacity modifier — bg-yellow-400 at 10% opacity. Creates a subtle yellow tint background when favourited |
| movie.credits?.cast?.length > 0 | Double 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-2 | Horizontal scroll for the cast strip. pb-2 adds bottom padding so the scrollbar does not overlap content |
| shrink-0 w-24 | Prevents each cast card from shrinking in the flex container. Fixed 6rem width for all cards |
Place the ReviewForm from Chapter 9 at the bottom of the detail page.
Code — add to MovieDetailPage.jsximport { 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
| Line | What it does |
|---|---|
| import ReviewForm | Imports 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-xl | Constrains the review form to a comfortable 36rem width — a form wider than this looks awkward |
- Sign in with Google — avatar appears in Navbar ← Chapter 2 (Day 1)
- Trending movies appear on the home page ← Chapter 3 (Day 1)
- Type in the search box — pause 400ms — results appear (check Network tab: one request) ← useDebounce
- Click a genre filter — grid narrows ← useMemo
- Click a sort button — movies reorder ← useMemo
- Add a movie to Watchlist — button turns red ← useReducer + WatchlistContext
- Click ♥ — heart turns yellow
- Navigate to /watchlist — both lists appear, sorted by most recently added ← useLocalStorage
- Refresh the page — watchlist and favourites still there ← localStorage persistence
- Click a movie card — detail page with backdrop, poster, cast row ← useParams, Chapter 10
- Add to Watchlist from the detail page — navigating back to home shows the card already marked ← shared WatchlistContext
- Go to /profile — trigger an error by tabbing out of Name immediately, fill valid data, submit — "Profile saved! ✓" ← useForm + Zod
- Scroll to Review section on any movie — fill stars and text, submit — server error appears (fictional endpoint), confirming error handling works ← AbortController
- 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
Reference
Glossary
| Term | Plain-English definition |
|---|---|
| AbortController | Browser API for cancelling in-flight fetch requests. Consists of a controller with abort() and a signal passed to fetch. |
| Action creator | Function returning an action object — prevents typos in type strings, enables editor autocomplete. |
| Controlled component | Form input whose value is driven by React state. Requires both value and onChange. |
| Curried function | A function that returns another function. handleSubmit(onSubmit) returns an async event handler — this is currying. |
| Custom hook | JavaScript function starting with use that calls React hooks to encapsulate reusable stateful logic. |
| Debounce | Delay reacting to a rapidly changing value until it stops changing for a set period. |
| dispatch | Function from useReducer that sends an action to the reducer, triggering a state update. |
| Pure function | Given the same inputs always returns the same output with no side effects. Reducers must be pure. |
| Reducer | Pure function (state, action) → newState. Must return a new object or array, never mutate in place. |
| response.ok | Boolean on the fetch Response. True for 200–299. Must be checked manually — fetch does not throw on 4xx/5xx. |
| Touched | Whether a user has visited and left a form field (via onBlur). Controls when validation errors are shown. |
| Uncontrolled component | Form input whose value lives in the DOM. Read via a ref at submit time. Uses defaultValue instead of value. |
| useCallback | Hook that memoizes a function reference between renders. |
| useMemo | Hook that memoizes a computed value between renders. |
| useParams | React Router hook that reads URL parameters. In /movie/:id, useParams() returns { id: '550' }. |
| useReducer | Hook for managing complex state with a reducer function. Returns [state, dispatch]. |
| Zod | Schema validation library. Declares rules as composable schemas. safeParse validates without throwing. |