The React frontend of our illustration project β the Multi-Source Product Review Aggregator β on top of the base Minimal Product Catalog full-stack app (fullstack-minimal-app).
Part 1 β React fundamentals: components & JSX, props, state (useState), events & controlled forms β all taught on review components (ReviewCard, ReviewList).
Part 2 β Wiring data:useEffect + async/await behind a data hook β sample data in S3, the backend on :4000 (the { data, meta } envelope) from S4 β plus React Router 7.9, Tailwind 3.4 build-time (not a CDN).
The arc: we convert the Session 2 vanilla-JS review list (fetch + hand-built DOM) into React review components. In S3 they render sample review data you create in the frontend; from S4 the very same components are fed by the backend review routes.
Using: the product catalog of fullstack-minimal-app (React 19.1 Β· Vite 7.1 Β· Tailwind 3.4 Β· React Router 7.9) as the foundation. The shipped app is catalog-only β it does not ship review UI, review components, review routes, or a reviews table. Building the review layer is this session. Philosophy: AI-assisted work is optional from S3 onward β but whether you generate or type the code, you are the quality gate: generate β verify β integrate β commit.
π€ AI-assisted work (optional β you are the quality gate)
The loop you will run for every concept
You are the senior developer π― Β· a coding agent, if you use one, is your junior assistant π€
Using an AI agent is optional in this session. What is not optional is the loop β for every concept below:
Concept β the idea, stated plainly.
Generate β with an agent: hand it the CRISP copy-paste prompt; without one: write the component yourself.
Verify β a short βcommon AI mistakesβ checklist, run on the code either way.
Integrate β a checkpoint in the browser, then a git commit.
generate β verify β integrate β commit. You are learning to verify, not memorize.
π Where you are & what is new
You are in Session 3. In S1 you built the static review card (semantic HTML + CSS, no JS) and covered the CRISP prompting workflow; in S2 you built a working vanilla-JS review list (fetch of a local reviews.json + async/await + manual DOM manipulation) and saw why it gets painful. Today you replace that hand-built DOM with React review components.
Central theme: a multi-source product review aggregator. Base (shipped): the Minimal Product Catalog (products + categories) β the foundation you build on. It ships the catalog UI and the catalog API; it ships no review UI, no review components, no review routes, no reviews table. This session (S3): build the review layer in React β ReviewCard, ReviewList, and where appropriate ReviewStats / ReviewForm β rendering sample review data you create in the frontend (frontend/src/data/reviews.js, a file you author). Capstone (S4 / S5): the same components are fed by the backend review routes you build in S4 (in-memory store) and persist in S5 (MySQL reviews table).
Props, state (useState), events & controlled forms
useEffect data-fetching behind a hook (sample data now, backend in S4)
React Router 7.9 (list / detail / form)
Tailwind 3.4 build-time (not CDN)
β Not covered (yet): Node/Express backend internals (S4), MySQL & mysql2 (S5).
π§± The real stack (fullstack-minimal-app)
This is the ground truth you will build against. Do not substitute versions or endpoints.
Layer
Technology
Runs on
Frontend
React 19.1 Β· Vite 7.1 Β· React Router 7.9
http://localhost:5173
Styling
Tailwind 3.4 β build-time (PostCSS), not a CDN
β
Backend
Node 22 Β· Express 4.18 Β· mysql2
http://localhost:4000
Database
MySQL 8.0 β db minimal_app_db, user appuser
:3306
Mock review scraper
Express
:5000 β home stretch only (not wired in the 18 h)
The frontend reaches the backend through the environment variable VITE_BACKEND_URL (default http://localhost:4000).
flowchart TD
subgraph B["π₯οΈ Browser Β· :5173 (dev server)"]
UI["React 19.1 UI Β· Vite 7.1 Β· Tailwind 3.4"]
end
subgraph S["π₯οΈ Backend Β· :4000 (Node 22 Β· Express 4.18)"]
API["Catalog API Β· GET /api/products (shipped)"]
REV["Review routes (you build them in S4) Β· GET /api/products/:id/reviews Β· /aggregate"]
end
DB[("πΎ MySQL 8.0 Β· minimal_app_db")]
MEM[("π§ In-memory store Β· S4")]
SCR["π€ Mock scraper Β· :5000 (home stretch)"]
UI -->|"fetch β { data, meta }"| API
UI -->|"fetch β { data, meta }"| REV
API -->|"SQL"| DB
REV -->|"S4"| MEM
REV -->|"S5"| DB
REV -.->|"POST /fetch (home stretch)"| SCR
style B fill:#E1F5FF,stroke:#4A90E2,stroke-width:3px
style S fill:#FFF4E1,stroke:#F5A623,stroke-width:3px
style UI fill:#61DAFB,stroke:#2E5C8A,stroke-width:2px,color:#000
style API fill:#F5A623,stroke:#C17D11,stroke-width:2px,color:#fff
style REV fill:#F5A623,stroke:#C17D11,stroke-width:2px,color:#fff
style DB fill:#E85D75,stroke:#B93A52,stroke-width:2px,color:#fff
style MEM fill:#B39DDB,stroke:#7E57C2,stroke-width:2px,color:#000
style SCR fill:#7ED321,stroke:#5FA119,stroke-width:2px,color:#000,stroke-dasharray: 5 5
π¨ The one rule that saves you (Pitfall #6)
The React UI only ever talks to :4000 (via VITE_BACKEND_URL). It never calls any other service directly.
A productβs reviews are designed to reach the UI only through the backend review routes GET /api/products/:id/reviews (list) and GET /api/products/:id/aggregate (stats) β routes you build in S4. They do not respond yet in S3; this session the review components render sample data from frontend/src/data/reviews.js, behind a hook so the switch to :4000 in S4 changes no component.
In S4 those routes read an in-memory store; in S5 they read the MySQL reviews table. The on-demand scraper (:5000) is the home stretch β not wired in the 18 h.
π RECALL / if needed
RECALL / if needed β JSX, props & state in 60 seconds
JSX is JavaScript + XML. A component is a function that returns a single root element (wrap extras in <>β¦</> or a <div>).
Use className (not class), htmlFor (not for), onClick (not onclick), and style={{β¦}} (a JS object). Self-close empty tags: <img β¦ />.
Props are read-only arguments passed like <ReviewCard review={r} />; destructure them in the signature. Numbers/booleans go in braces (rating={4}), strings can go in quotes (source="Amazon").
State is data that changes and triggers a re-render. Create it with useState: const [count, setCount] = useState(0). Always update through the setter (setCount(1)), never by reassigning.
Embed any expression in {}: {items.map(i => <li key={i.id}>{i.name}</li>)}.
RECALL / if needed β React Router in 60 seconds
React Router 7.9 gives an SPA its URLs. Wrap the app in <BrowserRouter> and declare <Route path="β¦" element={β¦} /> inside <Routes>.
Navigate with <Link to="/products/5"> (not <a> β a plain <a> reloads the page).
Read URL params with useParams(): on the route /products/:id, const { id } = useParams() gives the id.
Add useNavigate() for programmatic redirects (e.g. after a successful save).
Part 1: React fundamentals
From Session 2βs review list to React
This is the conversion we are here to make. Same job β βget the reviews, show themβ β two very different ways.
S2 style β vanilla: you fetch, then hand-build the DOM
asyncfunctionloadReviews() {const res =awaitfetch('./reviews.json');// S2: a local fileif (!res.ok) thrownewError('HTTP '+ res.status);const reviews =await res.json();renderReviews(reviews);}functionrenderReviews(list) {const box =document.querySelector('#review-list'); box.innerHTML='';// you rebuild the DOM by handfor (const r of list) {const card =document.createElement('article'); card.textContent=`${r.source} Β· ${r.rating}β β ${r.title}`; box.appendChild(card); }}
S3 style β React: declare what the UI is; React re-renders on state change
functionReviewSection({ productId }) {const [reviews, setReviews] =useState([]);const [loading, setLoading] =useState(true);useEffect(() => {let cancelled =false; (async () => {try {// S3: the hook resolves sample reviews; from S4 the same call// fetches GET /api/products/:id/reviews from the backend on :4000.const list =awaitgetReviewsForProduct(productId);if (!cancelled) setReviews(list); } finally {if (!cancelled) setLoading(false); } })();return () => { cancelled =true; }; }, [productId]);return (<ul>{loading ? <li>Loadingβ¦</li>: reviews.map(r => <likey={r.review_id}>{r.source} Β· {r.rating}β β {r.title}</li>)}</ul> );}
React is declarative: you describe what the UI should look like for a given state; React handles the DOM. No innerHTML, no appendChild.
1.1 Components & JSX
Concept. A component is a function returning JSX. Components are reusable and composable (you nest them). This is the pattern that keeps a growing UI maintainable β each feature is an isolated, testable piece.
AI generates (CRISP prompt β or write it yourself; the verify step is the same either way):
Context: React 19 frontend built with Vite, styled with Tailwind CSS (build-time).
Task: Create a presentational component `ReviewCard.jsx` that renders ONE review.
Props: a single `review` object with 8 fields: review_id (string, e.g. "AMZ_0001"),
source ("Amazon" | "BestBuy" | "Walmart"), author (string), rating (integer 1-5),
title (string), body (string), created_at (ISO string), verified_purchase (boolean).
Constraints: single default-export function component; one root element; Tailwind
classes only (no CDN, no inline <style>); a 5-star scale derived from rating; the
"Verified purchase" badge renders ONLY when verified_purchase is true; no state, no
fetching; no extra fields (no vote counter, no "was this useful?" flag).
Output: complete ReviewCard.jsx file.
You verify β common AI mistakes
β Multiple root elements without a Fragment β wrap in <>β¦</>.
β class= instead of className= (also forβhtmlFor, onclickβonClick).
β Template literals in JSX markup β <p>${review.rating}</p> renders the literal characters ${}; the correct JSX interpolation is <p>{review.rating}</p>. Template literals belong in plain JS strings, not in JSX children.
β Plain strings where a prop expects a value β numbers/booleans go in braces: rating={4}, verified_purchase={true}.
β One root, correct JSX attribute names, props used as data.
You integrate. Render it in a scratch harness with one sample review, then commit:
git commit -m"feat(ui): add presentational ReviewCard component with props"
1.2 Props: making components reusable
Concept. Props are read-only arguments. Define a component once, use it with different data. Data flows down (parent β child); children never mutate props.
import ReviewCard from'./ReviewCard';import { SAMPLE_REVIEWS } from'../data/reviews';// the sample module YOU create in S3functionReviewGrid() {const reviews = SAMPLE_REVIEWS['1'] ?? [];// sample reviews for product id 1return (<divclassName="grid grid-cols-1 md:grid-cols-3 gap-4">{reviews.map((r) => <ReviewCardkey={r.review_id}review={r}/>)}</div> );}
AI generates (CRISP prompt):
Context: React 19 + Tailwind (build-time). I already have ReviewCard.jsx and a
sample data module frontend/src/data/reviews.js exporting SAMPLE_REVIEWS (an
object keyed by product id, each value an array of 8-field review objects).
Task: Create a `ReviewGrid.jsx` that renders a responsive 3-column grid of
ReviewCard for one product's reviews.
Constraints: pass each review via the `review` prop; do NOT modify the reviews
(props and the imported array are read-only); use key={review_id}; Tailwind grid
utilities only.
Output: complete ReviewGrid.jsx file.
You verify β common AI mistakes
β Treating props as state (calling a setter on a prop, or mutating review.rating).
β Hardcoding reviews instead of threading them through props / the data module.
β Missing or unstable key on the .map β use key={r.review_id}.
β Each usage passes explicit props; the child only reads them.
You integrate. Confirm the grid renders several distinct review cards, then:
git commit -m"feat(ui): compose ReviewCard into a responsive ReviewGrid"
1.3 State with useState
Concept. State is data that changes over time and triggers a re-render. Create it with the useState hook. Each piece is independent and local to the component.
Why not a plain variable? React has no way to know a regular variable changed, so it never re-renders. useState tells React βthis value matters β re-render when it changes.β
AI generates (CRISP prompt):
Context: React 19 + Tailwind (build-time).
Task: Create `SourceFilter.jsx` that lets the user pick a review source and stores the
choice in local state.
Props: sources (array of strings, e.g. ["All", "Amazon", "BestBuy", "Walmart"]) and
onSelect (callback).
Constraints: use useState for the selected value; render a <select> bound to that state
(value + onChange); call onSelect with the new value; show the current selection as text;
no fetching.
Output: complete SourceFilter.jsx file.
You verify β common AI mistakes
β Reassigning state (selected = 'Amazon') instead of calling the setter (setSelected('Amazon')).
β Forgetting import { useState } from 'react'.
β The setter is the only way state changes; the UI reflects it on re-render.
You integrate. Change the select, confirm the displayed selection updates, then:
git commit -m"feat(ui): add SourceFilter (per-review-source selection) with useState"
1.4 Events & controlled forms (what ReviewForm needs)
Concept. React handles events with camelCase handlers (onClick, onChange, onSubmit). A controlled input binds value to state and updates that state in onChange, so React is the single source of truth for form data.
Pattern:value={state} + onChange={setState} = a controlled input. You will reuse it in S3βs practice for the ReviewForm fields (author, rating, title, body).
AI generates (CRISP prompt):
Context: React 19 + Tailwind (build-time).
Task: Create a minimal controlled search form `ReviewSearch.jsx` for filtering reviews.
Constraints: one text input bound to a useState value; an onSubmit handler that calls
e.preventDefault(); show the current term; Tailwind classes only.
Output: complete ReviewSearch.jsx file.
You verify β common AI mistakes
β Uncontrolled input β no value, or value not tied to state (typing does nothing / warnings).
β Missing e.preventDefault() on submit (the page reloads).
2.1 Data fetching: useEffect + async/await against :4000
Concept.useEffect runs side effects (like fetching) after render. It takes an effect function and a dependency array. Fetching on mount = useEffect(β¦, []); fetching again when a value changes = useEffect(β¦, [value]). Always return a cleanup so you never set state on an unmounted component.
The envelope you must respect. The shipped catalog API does not return bare arrays β GET /api/products returns:
So you read json.data for the items and json.meta for pagination. GET /api/categories returns { "data": [category] }. The review routes you build in S4 reuse this same envelope β the fetch pattern below is exactly the one they will need.
AI generates (CRISP prompt):
Context: React 19 frontend. Reviews have 8 fields: review_id (string, e.g. "AMZ_0001"),
source ("Amazon" | "BestBuy" | "Walmart"), author (string), rating (integer 1-5),
title (string), body (string), created_at (ISO string), verified_purchase (boolean).
In this session, reviews come from a sample module frontend/src/data/reviews.js that
exports SAMPLE_REVIEWS, an object keyed by product id with arrays of review objects.
(From S4, the same hook will instead call the backend on import.meta.env.VITE_BACKEND_URL:
GET /api/products/:id/reviews, which returns the { data, meta } envelope β read json.data.)
Task: Create frontend/src/hooks/useProductReviews.js exporting
useProductReviews(productId) that returns { reviews, loading, error }.
Constraints: useState for reviews/loading/error; ONE useEffect keyed on [productId]
that resolves the reviews asynchronously (await a Promise so the async shape is real),
with a cancelled flag and a cleanup function; never mutate the imported array; keep the
public shape stable so components never change when the sample import becomes a fetch.
Output: complete useProductReviews.js file.
You verify β common AI mistakes
β Reading the wrong key later β when S4 switches this hook to fetch, the reviews live in json.data, not json (the { data, meta } envelope).
β Async work in the render body (outside useEffect).
β Hardcoded URL β when the fetch arrives, it must be ${API_BASE}/β¦ with API_BASE = import.meta.env.VITE_BACKEND_URL, never a literal http://localhost:4000.
β loading/error states are shown; the hookβs public shape ({ reviews, loading, error }) matches what the components consume.
You integrate. Render ReviewGrid through the hook with a sample product id, confirm the cards appear (loading β list), then:
git commit -m"feat(ui): add useProductReviews hook (sample data now, :4000 in S4)"
2.2 React Router 7.9: list / detail / form
Concept. React Router 7.9 maps URLs to components. The shipped catalog already routes between its own views; your review layer plugs into the same router β for example a dedicated reviews view addressed by product id, reachable with <Link> and reading :productId via useParams.
import { BrowserRouter, Routes, Route, Link, Navigate } from'react-router-dom';import ProductList from'./components/ProductList';// shipped catalog viewimport ReviewsPage from'./components/ReviewsPage';// you create this in S3functionApp() {return (<BrowserRouter><navclassName="p-4 flex gap-4"><Linkto="/">Catalog</Link><Linkto="/reviews/1">Reviews (product 1)</Link></nav><Routes><Routepath="/"element={<ProductList/>}/><Routepath="/reviews/:productId"element={<ReviewsPage/>}/><Routepath="*"element={<Navigateto="/"replace/>}/></Routes></BrowserRouter> );}exportdefault App;
AI generates (CRISP prompt):
Context: React 19 with React Router 7.9 (react-router-dom). The catalog routes
already exist in App.jsx. I have ReviewGrid.jsx and the useProductReviews hook.
Task: Create ReviewsPage.jsx β it reads :productId from useParams(), calls
useProductReviews(productId), and renders the review grid with loading/error
states β then add the route "/reviews/:productId" -> ReviewsPage and a <Link>
to /reviews/1 in the nav, keeping the existing catalog routes untouched.
Constraints: <Link> (not <a>) for in-app navigation; useParams for the id; do not
modify the shipped catalog routes; Tailwind for layout.
Output: complete ReviewsPage.jsx plus the modified App.jsx.
You verify β common AI mistakes
β <a href> for in-app navigation (causes a full page reload) β must be <Link to>.
β Route path not matching the param (e.g. useParams().productId but the route is /reviews/:id).
β Missing the catch-all β unknown URLs show a blank screen.
β Overwriting the shipped catalog routes instead of adding to them.
β Params line up; navigation is client-side; a fallback route exists.
You integrate. Click the nav link, confirm the URL changes and the reviews render without a page reload, then:
git commit -m"feat(ui): add /reviews/:productId route with ReviewsPage + Link"
2.3 Tailwind 3.4, build-time (not a CDN)
Concept. Tailwind is a set of utility classes (flex, p-4, text-2xl). In this project it runs at build time through PostCSS, not via a CDN <script> tag. Three files make it work:
File
Role
tailwind.config.cjs
Config β includes a content glob so Tailwind knows which files to scan
postcss.config.cjs
Wires Tailwind into the PostCSS build pipeline
src/styles.css
The @tailwind directives that emit the CSS
/* src/styles.css β these three lines are what make build-time Tailwind work */@tailwind base;@tailwind components;@tailwind utilities;
AI generates (CRISP prompt):
Context: Vite 7.1 + React project using Tailwind CSS 3.4 at BUILD time.
Task: Create the build-time Tailwind setup: tailwind.config.cjs with a content glob
covering ./index.html and ./src/**/*.{js,jsx}; postcss.config.cjs enabling the
tailwindcss and autoprefixer plugins; and src/styles.css with the three @tailwind
directives.
Constraints: build-time only β do NOT add a CDN <script> to index.html.
Output: the three files' contents.
You verify β common AI mistakes
β Tailwind via CDN β a <script src="https://cdn.tailwindcss.com"> in index.html (prototyping only; wrong for a build).
β @tailwind directives missing from src/styles.css, or the CSS file not imported by main.jsx.
β content glob too narrow β utilities get purged and styles vanish.
β postcss.config.cjs + tailwind.config.cjs + the three directives are all present.
You integrate. Confirm classes apply in the browser (no CDN tag in the built HTML), then:
git commit -m"chore(ui): configure Tailwind 3.4 at build time via PostCSS"
The review layer you build in S3
The shipped frontend gives you the catalog (list / detail / form views wired to GET /api/products on :4000). Everything review-related below is created by you in this session β it does not exist in the shipped repo. The catalog views are context, not the exercise.
Shipped vs. student-created (S3 map)
Path
Status
Role
frontend/src/main.jsx, App.jsx
shipped
entry point + router (you add a reviews route)
catalog views (list / detail / form)
shipped
the product foundation β not todayβs exercise
frontend/src/data/reviews.js
you create (S3)
sample reviews, 8-field schema
frontend/src/hooks/useProductReviews.js
you create (S3)
data hook: sample now, :4000 in S4
frontend/src/components/ReviewCard.jsx
you create (S3)
presentational card for one review
frontend/src/components/ReviewList.jsx
you create (S3)
grid + per-source filter + loading/empty states
frontend/src/components/ReviewStats.jsx
you create (S3)
average + per-source + 1β5 histogram
frontend/src/components/ReviewForm.jsx
you create (S3)
controlled form building the canonical payload
The sample data module (you create it)
frontend/src/data/reviews.js is the seam that keeps S3 buildable before the backend review routes exist (S4) and the reviews table exists (S5). Group reviews by product id; use string review_ids of the form "AMZ_0001":
// frontend/src/data/reviews.js β YOU create this file in S3 (not in the shipped repo)exportconst SAMPLE_REVIEWS = {"1": [ {review_id:"AMZ_0001",source:"Amazon",author:"j.doe",rating:5,title:"Great sound",body:"Bass is strong and the battery lasts all day.",created_at:"2026-09-01T10:00:00Z",verified_purchase:true, }, {review_id:"BBY_0002",source:"BestBuy",author:"a.martinez",rating:4,title:"Solid, with a caveat",body:"Comfortable after a break-in period.",created_at:"2026-09-03T18:30:00Z",verified_purchase:true, }, {review_id:"WMT_0003",source:"Walmart",author:"k.wong",rating:2,title:"Stopped pairing",body:"Worked for two weeks, then would not reconnect.",created_at:"2026-09-05T08:15:00Z",verified_purchase:false, }, ],// "2": [ ... more sample reviews for other products ... ]};
product_id on a review? No β not as a React prop. The reviewβοΈproduct link is the route path (/api/products/:id/reviews) in S4 and a database foreign key in S5. Your S3 components receive one productβs reviews array and never need a product_id field inside a review.
The full ReviewCard (the version you aim for)
// frontend/src/components/ReviewCard.jsx β you create this in S3exportdefaultfunctionReviewCard({ review }) {const sourceColor = {Amazon:'bg-orange-100 text-orange-700',BestBuy:'bg-blue-100 text-blue-700',Walmart:'bg-green-100 text-green-700', }[review.source] ??'bg-gray-100 text-gray-700';// rating is an integer 1-5; displayed on a standard 5-star scalereturn (<divclassName="bg-white rounded-lg shadow p-4"><divclassName="flex items-center gap-2 mb-2"><spanclassName={`text-xs font-semibold px-2 py-0.5 rounded ${sourceColor}`}>{review.source}</span><spanclassName="text-amber-500"aria-label={`Rating ${review.rating} out of 5`}>{'β '.repeat(review.rating)}{'β'.repeat(5- review.rating)}</span>{review.verified_purchase&& <spanclassName="text-xs text-green-600">β Verified</span>}</div><h4className="font-semibold">{review.title}</h4><pclassName="text-sm text-gray-700">{review.body}</p><pclassName="text-xs text-gray-400 mt-2">{review.author} Β· {newDate(review.created_at).toLocaleDateString()}</p></div> );}
Note the two interpolation forms and when each is right: className={`β¦ ${sourceColor}`} is a plain-JS template literal inside braces (allowed); {review.title} is JSX expression interpolation (the normal case). What is never right is a bare ${review.title} sitting directly in JSX markup β that renders the literal text ${review.title}.
ReviewList β map + filter + states
// frontend/src/components/ReviewList.jsx β you create this in S3import { useState } from'react';import ReviewCard from'./ReviewCard';const SOURCES = ['All','Amazon','BestBuy','Walmart'];exportdefaultfunctionReviewList({ reviews, loading }) {const [source, setSource] =useState('All');// derive a NEW array; never mutate the reviews propconst visible = source ==='All'? reviews : reviews.filter((r) => r.source=== source);if (loading) return <pclassName="text-gray-500">Loading reviewsβ¦</p>;if (reviews.length===0)return <pclassName="text-gray-500">No reviews available yet.</p>;return (<divclassName="space-y-4"><divclassName="flex items-center gap-3"><label> Source{' '}<selectvalue={source}onChange={(e) =>setSource(e.target.value)}>{SOURCES.map((s) => <optionkey={s}value={s}>{s}</option>)}</select></label><spanclassName="text-sm text-gray-500">{visible.length} of {reviews.length} reviews</span></div><divclassName="grid grid-cols-1 md:grid-cols-2 gap-4">{visible.map((r) => <ReviewCardkey={r.review_id}review={r}/>)}</div></div> );}
The review shape (exactly 8 fields)
Field
Type
Example
Notes
review_id
string
"AMZ_0001"
unique review id β use as the React key
source
string
"Amazon" | "BestBuy" | "Walmart"
where the review came from
author
string
"j.doe"
reviewer name
rating
integer
4
1β5 (standard 5-star scale)
title
string
"Great sound"
short headline
body
string
"Bass is strongβ¦"
full review text
created_at
string (ISO)
"2026-09-01T10:00:00Z"
timestamp
verified_purchase
boolean
true
whether the reviewer bought it
There is no helpful field in this schema β do not render or fetch one.
How the reviews flow (S3 sample module now Β· backend review routes from S4)
flowchart TD
UI["React UI Β· :5173"]
HOOK["useProductReviews hook"]
SAMPLE["Sample module Β· data/reviews.js (S3, you create it)"]
API["Express Β· :4000 (review routes: you build them in S4)"]
STORE["Store Β· in-memory (S4) / MySQL (S5)"]
UI --> HOOK
HOOK -->|"S3: read sample reviews"| SAMPLE
HOOK -.->|"S4: GET /api/products/1/reviews"| API
API -.->|"read product 1's reviews"| STORE
STORE -.->|"[review, β¦]"| API
API -.->|"200 { data, meta }"| UI
style UI fill:#61DAFB,stroke:#2E5C8A,stroke-width:2px,color:#000
style SAMPLE fill:#7ED321,stroke:#5FA119,stroke-width:2px,color:#000
style API fill:#F5A623,stroke:#C17D11,stroke-width:2px,color:#fff
style STORE fill:#B39DDB,stroke:#7E57C2,stroke-width:2px,color:#000
Bridge to the practice (TP 3)
In the practice you build these review components yourself (optionally with an agent). A healthy order: render ReviewCard / ReviewList from the sample module first, keep every data access behind useProductReviews, so that in S4 β when GET /api/products/:id/reviews finally answers on :4000 β you change the hook only, never the components.
π€ AI-generate β You verify (the React/JSX beat)
Here is one CRISP prompt that produces the whole reviews section (use an agent if you like β then verify as written), followed by the master checklist you run on any generated React code.
Context: React 19 frontend (Vite) for a multi-source product review aggregator.
A review has 8 fields: review_id (string, e.g. "AMZ_0001"),
source ("Amazon"|"BestBuy"|"Walmart"), author (string), rating (integer 1β5),
title (string), body (string), created_at (ISO string), verified_purchase (boolean).
There is NO "helpful" field. In this session reviews come from the
useProductReviews(productId) hook (sample module frontend/src/data/reviews.js);
from S4 the same hook will call the backend on import.meta.env.VITE_BACKEND_URL
(default http://localhost:4000) via GET /api/products/:id/reviews.
Task: Create ReviewsPage.jsx that reads :productId with useParams, uses the hook,
and renders a heading ("Reviews from N sources (M total)") plus a grid of
ReviewCard for the product's reviews.
Constraints: no fetch here (the hook owns data access); render loading/error states;
use key={review.review_id}; render a source badge and a 5-star scale from rating;
Tailwind (build-time) classes only.
Output: complete ReviewsPage.jsx file.
You verify β the master βcommon AI mistakesβ checklist
Run these seven checks on the generated file before you trust it:
Missing key on .map β reviews.map(r => <ReviewCard review={r}/>) β add a stable key={r.review_id}.
State mutation β reviews.push(newR) or form.author = x β use the setter with a new value: setForm({ ...form, author: x }).
Async work in render β a fetch(...) / data call in the component body β move it inside useEffect (or into the hook).
No useEffect cleanup β missing return () => β¦ β return a cleanup that flips a cancelled flag (prevents setting state after unmount).
Wrong response key (S4 preview) β when the hook switches to fetch, setReviews(json) is the bug β read the envelope: setReviews(json.data) (and json.meta).
Hardcoded URL β fetch('http://localhost:4000/β¦') β use fetch(`${API_BASE}/β¦`) where API_BASE = import.meta.env.VITE_BACKEND_URL.
Tailwind via CDN β a <script src="https://cdn.tailwindcss.com"> in index.html β keep build-time Tailwind (postcss.config.cjs + @tailwind directives in src/styles.css).
π Prompt templates (CRISP, reusable)
Save these three; swap the brackets.
1 β Generate a presentational component
Context: React 19 + Vite, Tailwind 3.4 (build-time).
Task: Create <ComponentName>.jsx that renders <what it shows>.
Props: <name (type)> for each piece of data.
Constraints: single default-export function component; one root element;
className (not class); Tailwind classes only; no state, no fetching.
Output: complete <ComponentName>.jsx file.
2 β Generate a data-fetching component
Context: React 19. Backend base URL is import.meta.env.VITE_BACKEND_URL
(default http://localhost:4000). <ENDPOINT> returns <the exact response shape>.
Task: Create <ComponentName>.jsx that fetches <what> and renders it.
Constraints: useState for data/loading/error; useEffect with a cancelled flag and
a cleanup function; check res.ok; read the correct response key (json.data);
stable key in the .map; Tailwind classes only.
Output: complete <ComponentName>.jsx file.
3 β Convert a vanilla-JS snippet to a React component
Context: I have a vanilla-JS snippet that uses fetch + async/await and builds the
DOM with innerHTML / createElement. Rewrite it as a React 19 function component.
Snippet: <paste the vanilla code>
Constraints: replace manual DOM with JSX + useState + useEffect; add a cleanup
function; keep the same data source and response shape; use
import.meta.env.VITE_BACKEND_URL for the base URL; Tailwind classes only.
Output: the React component.
π Key takeaways
React is declarative. You describe the UI for a given state; React re-renders the DOM. This is the upgrade from S2βs manual innerHTML/appendChild.
Three core ideas:components (functions returning JSX), props (read-only data flowing down), state (data that changes, via useState + its setter).
The review layer is yours: the shipped app is catalog-only. In S3 you createdata/reviews.js, useProductReviews, ReviewCard, ReviewList (and the practice adds ReviewStats / ReviewForm).
Data access lives in the hook.useProductReviews reads the sample module in S3; in S4 the very same hook reads GET /api/products/:id/reviews from :4000 β the components never change.
Fetch inside useEffect, not in render β and always return a cleanup so you never set state on an unmounted component.
Respect the { data, meta } envelope. The catalog API already uses it (items in json.data, pagination in json.meta); the review routes you build in S4 reuse the same contract. Reading the wrong key is the #1 silent bug.
Never hardcode the URL. Use import.meta.env.VITE_BACKEND_URL (default http://localhost:4000) so the frontend stays configurable.
In S4 the review routes read an in-memory store; in S5 the MySQL reviews table. Reviews are designed to reach the UI only through GET /api/products/:id/reviews. The on-demand scraper (:5000) is the home stretch, not wired in the 18 h.
A review is exactly 8 fields (review_id, source, author, rating 1β5, title, body, created_at, verified_purchase). No helpful field.
Tailwind is build-time here: tailwind.config.cjs + postcss.config.cjs + @tailwind directives β not a CDN <script>.
The working loop (agent optional): generate β verify against the mistakes checklist β integrate (browser checkpoint) β commit. You are the quality gate.
Next up (Session 4): the Node 22 + Express 4.18 backend on :4000 β including the review routes (in-memory) β and then Session 5, the MySQL 8.0 database (reviews table).