Study Index
React · In Practice · Netflix GPT

React, shipping the thing

A separate notebook for the project phase — building a real Netflix-style app with auth, forms, and GPT search. Each build log captures the decisions, the structure, and the interview-gold tradeoffs, not the live-coding fumbles.

Build 01

Project Setup & the Login Page

From an empty terminal to a working, validated sign-in / sign-up form — scaffolding, routing, forms, and refs.

A real app starts with decisions, not code: scaffold the project, map the routes, sketch the components. Then the first feature — one form that toggles between sign-in and sign-up via state, reads its inputs with refs, and validates before doing anything.

1.1

What we're building & why it's worth it

The project is a Netflix-style streaming app with a twist: a GPT-powered search that recommends movies from a plain-English prompt. It's chosen because it forces you through features a foundations course usually skips — real auth, real forms, and a third-party AI API.

Auth
Login + protected routes

A backend-as-a-service (Firebase) handles sign-up/sign-in, so the browse page is reachable only when logged in.

Forms
Sign-in / sign-up + validation

Hand-rolled form handling and validation — the bread-and-butter of almost every app.

GPT search
Prompt → movie picks

A search box that sends a natural-language query to a GPT API and renders the suggested titles.

  • Concepts > projects: the real takeaway isn't "Netflix" — it's the reusable building blocks. The same auth + form + external-API pattern builds an e-commerce site, a food app, or anything else.
  • UI matters here: this build leans into CSS (via Tailwind) to make it look real, not just function. Polish is part of the deliverable.
1.2

Scaffolding with Create React App

Instead of wiring a bundler by hand, this project starts from a scaffold — a ready-made folder structure, dev server, and tooling generated by one command. Create React App (CRA) gives you that out of the box.

// one command generates the whole project
npx create-react-app netflix-gpt
cd netflix-gpt
npm start            // dev server + hot reload, opens the browser
From scratch (Parcel)

You install and configure the bundler, testing library, Babel, etc. Maximum control, maximum setup time. Good to understand once.

vs
Scaffold (CRA)

One command sets up Webpack (the bundler), Jest + React Testing Library, and npm scripts (start, build, test). You start building immediately.

  • CRA bundles with Webpack: same role Parcel played earlier — it compiles and serves your code. You just don't configure it yourself.
  • Batteries included: testing is pre-wired (there's already a sample test), and hot reload works on save without any setup.
  • It's effectively a framework on top of React: React itself is a library, but CRA layers structure, build, and conventions around it — that's why your app boots instantly.
  • Alternatives exist & are rising: Vite is a faster, increasingly popular scaffold. CRA is fine to learn on; reach for Vite (or a framework like Next.js) on newer projects.
Nice to know
Clean the slate first

The CRA starter ships a demo page (spinning logo, sample CSS). Delete the boilerplate JSX, logo import, and the contents of App.css / index.css before building — start from an empty canvas.

1.3

Adding Tailwind

Tailwind is a utility-first CSS toolkit — you style by composing tiny class names (text-3xl, font-bold, bg-red-700) right in the markup instead of writing separate CSS rules. Setup in a CRA project is three small steps.

// 1. install + generate config
npm install -D tailwindcss
npx tailwindcss init

// 2. tailwind.config.js - tell it which files to scan
content: ["./src/**/*.{js,jsx,ts,tsx}"],

// 3. index.css - pull in Tailwind's layers
@tailwind base;
@tailwind components;
@tailwind utilities;
  • The content path is not optional: Tailwind scans those files to know which classes you actually use, then ships only those (keeps the CSS tiny). Miss this and your styles won't apply.
  • Why utility-first pays off here: building a custom layout (overlapping header, gradient, centered form) is fast with utilities and painful in hand-written CSS — that's the whole reason it's used in this build.
1.4

Planning the app: routes & components

Plan before building. The app is two real pages plus an overlay feature, and the routing rule encodes the auth story: the browse page exists only for logged-in users; everyone else lands on login.

/

Login page — sign-in / sign-up form. Default landing.

/browse

Browse page — header, hero trailer, movie rows. Auth-only.

+ GPT

Search overlay on browse — prompt in, movie suggestions out.

Component structure stays hierarchical so App.js stays a thin root. Components live in /components; shared helpers and constants in /utils.

// App.js - thin root, only sets up routing
const appRouter = createBrowserRouter([
  { path: "/",       element: <Login /> },
  { path: "/browse", element: <Browse /> },
]);

const App = () => <RouterProvider router={appRouter} />;
  • Folder convention: /components for UI pieces (Login, Header, Browse), /utils for shared logic (validation, constants, config). Keeps the tree predictable as it grows.
  • Keep the root clean: do routing in App.js and nothing else — every real screen is its own component mounted by the router.
  • Routing is from react-router-dom: createBrowserRouter defines the path→element map; RouterProvider makes it live. (Same API as the foundations notes.)
Gotcha
"You cannot render a Router inside another Router"

This error means a component is recursively rendering itself (e.g. Body referencing Body). Each route element must point to the right screen — / → Login, not back into the router's own parent.

1.5

One form, two modes (the toggle pattern)

Sign-in and sign-up share almost everything, so don't build two forms — build one and flip it with a boolean state variable. This is the reusable idea: a single component whose UI changes based on state.

const [isSignIn, setIsSignIn] = useState(true);

const toggleForm = () => setIsSignIn(!isSignIn);

// title + button text react to the same flag
<h1>{isSignIn ? "Sign In" : "Sign Up"}</h1>

// extra field only in sign-up mode
{!isSignIn && <input type="text" placeholder="Full Name" />}

<button>{isSignIn ? "Sign In" : "Sign Up"}</button>
<p onClick={toggleForm}>
  {isSignIn ? "New here? Sign up now" : "Already registered? Sign in"}
</p>
  • Toggle with !prev: setIsSignIn(!isSignIn) flips the flag, and the whole form re-renders in the other mode — one state drives title, button, link text, and which fields show.
  • Conditional fields via &&: sign-up adds a name field; render it only when !isSignIn. No second form, no duplication.
  • Reusable far beyond auth: the same state-driven toggle swaps cards, tabs, or any either/or UI. That's the transferable lesson.
1.6

Reading inputs: useRef vs controlled state

To validate on submit, you need the values inside the inputs. Two ways: bind each field to state (controlled), or grab the field's value directly via a ref. For a submit-only read, a ref is lighter — no re-render on every keystroke.

const email = useRef(null);
const password = useRef(null);

<input ref={email} type="text" placeholder="Email" />
<input ref={password} type="password" placeholder="Password" />

const handleButtonClick = () => {
  // reach the DOM node's live value
  const e = email.current.value;
  const p = password.current.value;
};
useRef (uncontrolled)

The input owns its value; you read ref.current.value only when needed. No re-render per keypress. Great for read-on-submit forms.

vs
useState (controlled)

React owns the value; every keystroke updates state and re-renders. Needed when the UI must react live to typing (e.g. instant validation, disabled buttons).

  • What a ref actually is: useRef(null) returns a mutable box; attach it with ref={email} and React points email.current at the real DOM node. .current.value is the live input text.
  • Refs don't trigger renders: changing a ref is invisible to React's render cycle — that's exactly why they're cheap for "just read it when I click submit."
  • Rule of thumb: need the value continuously (live feedback)? Use state. Need it only at a moment (submit)? A ref is simpler.
Analogy · recall

A ref is a coat-check ticket for a DOM element. React hands you a numbered stub (email.current); whenever you want, you walk up and read what's there (.value) — without disturbing anyone in the room. State is a live scoreboard: every change is announced to the whole room (a re-render). Use the ticket when you only need to peek occasionally; use the scoreboard when everyone must see each change instantly.

1.7

Form validation, extracted to a utility

Validation logic doesn't belong inside the component — it bloats it. Put it in a pure helper in /utils that takes the values and returns an error message (or null for "all good"). The component stays a one-liner at the call site.

// utils/validate.js
export const checkValidData = (email, password) => {
  const isEmailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
  const isPwValid = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/.test(password);

  if (!isEmailValid) return "Email ID is not valid";
  if (!isPwValid)    return "Password is not valid";
  return null;   // null = no errors
};
// Login.js - validate on submit, show the message
const [errorMessage, setErrorMessage] = useState(null);

const handleButtonClick = (e) => {
  e.preventDefault();      // stop the form's page reload
  const msg = checkValidData(
    email.current.value,
    password.current.value
  );
  setErrorMessage(msg);     // renders the red text, or clears it
  if (msg) return;          // only proceed when valid
  // ...sign in / sign up here
};
  • Why e.preventDefault(): a button inside a <form> submits it by default, which reloads the page. This is plain web behavior, not a React quirk — prevent it so your handler runs instead.
  • Validate with regex: a regular expression is a pattern; regex.test(value) returns true/false. Email checks for name@domain.tld; password here wants 8+ chars with upper, lower, and a digit.
  • Return-message contract: the helper returns a string on failure or null on success. The component just stores it in state — truthy shows the error, null clears it.
  • State for the message, ref for the inputs: the error must update the UI live, so it's useState; the input values are read once on click, so they're refs. Clean division of the two hooks.
  • For big forms, reach for a library: hand-rolled validation is fine for two fields; with dozens, a form library (e.g. Formik) handles values, errors, and touched-state without the boilerplate.
1.8

Gotchas & nice-to-knows

Gotcha
Form reloads the page on click

A <button> in a form triggers submit → full reload. Call e.preventDefault() in the handler to stop it.

Gotcha
ref value is undefined

You read email.value instead of email.current.value. The ref is a box; the node lives on .current.

Gotcha
Tailwind classes do nothing

The content glob in the config doesn't match your files, so Tailwind purges everything. Point it at ./src/**/*.

Gotcha
Overlapping layout won't stack right

Use absolute for the background, a higher z-index for the header, so the form and nav sit above the hero image.

Nice to know
rafce snippet

Typing rafce in a JS/JSX file scaffolds a full arrow-function component with export — saves writing the boilerplate.

Nice to know
Validation as a pure function

Keeping checkValidData pure (inputs in, message out, no side effects) makes it trivial to reuse and unit-test.

1.9

Interview Q&A

Q.What is Create React App and what does it set up for you?
CRA is a scaffold that generates a ready-to-run React project from one command. It wires up a bundler (Webpack), a dev server with hot reload, testing (Jest + React Testing Library), and npm scripts — so you can start building instead of configuring. Vite is a faster modern alternative.
Q.What's the difference between a controlled and an uncontrolled component?
In a controlled component, React state holds the input's value and updates on every keystroke. In an uncontrolled one, the DOM holds the value and you read it with a ref when needed. Controlled is for live-reactive UIs; uncontrolled (refs) is lighter for read-on-submit.
Q.What is useRef and when would you use it over useState?
useRef gives you a mutable box whose .current can point at a DOM node or hold any value across renders — and changing it does not trigger a re-render. Use it to read an input's value on submit or to reference a DOM element; use state when a change must update the UI.
Q.How do you build sign-in and sign-up without duplicating the form?
Use one form and a boolean state flag (e.g. isSignIn). The flag drives the title, button text, link text, and which fields render. A toggle handler flips it with setIsSignIn(!isSignIn), re-rendering the form in the other mode.
Q.Why does a form reload the page on submit, and how do you stop it?
A button inside a <form> triggers the browser's native submit, which reloads the page. It's standard web behavior, not React-specific. Call e.preventDefault() at the top of your click/submit handler so your code runs instead.
Q.How would you validate a form, and where should that logic live?
Put validation in a pure helper function (in a utils file) that takes the values and returns an error message or null. Often you test with regex — e.g. regex.test(email) for format. The component just calls it on submit and stores the result in state to show or clear the error.
Q.What's a protected route and how does it fit the routing plan?
A protected route is one only authenticated users can reach. Here, /browse is protected: a logged-out user who tries it gets redirected to the login page (/). The router maps paths to screens, and the auth check decides whether the user is allowed through.
Q.Why split code into /components and /utils?
It keeps the project predictable as it grows: /components holds UI pieces, /utils holds shared, non-visual logic like validation, constants, and config. Pulling logic out of components keeps them small, focused, and easier to reuse and test.
plan routes & components first CRA scaffold (Webpack under the hood) Vite = faster alternative Tailwind: set content glob thin App.js = routing only /components + /utils /browse = protected route one form, isSignIn toggle conditional field via && useRef = read on submit useState = live UI ref.current.value e.preventDefault() validate.js returns msg | null regex.test(value) error in state, inputs in refs big forms → Formik rafce snippet
Build 02

The Auth Guard & the Browse Page

Centralize the auth listener so routes guard themselves, then fetch real movie data and render the browse screen — cleanly.

Routing shouldn't be scattered across components. Put one auth listener in a place that's always mounted (the header), let it redirect on every auth change, then feed the browse page from the store via a custom hook — so each component does one job.

2.1

The problem: routes that don't guard themselves

Auth works (sign-in / up / out exist), but two holes remain. A logged-out user can still open /browse directly, and a logged-in user landing on the login page should be bounced to browse. Both need the app to react to auth state, not just set it once.

Hole 1
Browse is reachable when logged out

Typing the /browse URL with no user shows private content. It must redirect to login.

Hole 2
Login is reachable when logged in

A signed-in user on / should be sent straight to /browse — the way real Netflix behaves.

Firebase already gives the signal: onAuthStateChanged is a listener that fires whenever the user logs in or out. The fix is about where you run it.

2.2

Centralizing the listener in the header

A redirect can only happen from a component inside the router (otherwise useNavigate throws "may only be used in the context of a Router"). The header is mounted on every page and lives inside the router — so it's the one right home for the auth listener.

// Header.js - the always-on auth brain
useEffect(() => {
  const unsubscribe = onAuthStateChanged(auth, (user) => {
    if (user) {
      dispatch(addUser({ uid: user.uid, email: user.email }));
      navigate("/browse");   // logged in -> browse
    } else {
      dispatch(removeUser());
      navigate("/");          // logged out -> login
    }
  });
  return () => unsubscribe();   // cleanup on unmount
}, []);
  • One listener, every route: because the header is always rendered, this runs on every page. Any auth change — login, signup, logout — triggers the right redirect automatically.
  • Delete the scattered navigates: once this exists, you remove the manual navigate("/browse") from the login handler and the navigate("/") from sign-out. The listener owns all routing now — single source of truth.
  • Why not the body/parent: a component that renders the RouterProvider sits outside the routes, so its useNavigate has no router context. The header is a child of the routes, so it's safe.
Analogy · recall

The auth listener is a doorman in the lobby — one person, always on duty, who every guest passes. Instead of putting a separate bouncer at each floor (a navigate in every component), the doorman checks your badge once and sends you to the right floor. Put the doorman in a room that's always open (the header), and the whole building is covered.

2.3

Cleaning up the listener (useEffect return)

A listener that's never removed is a leak. Each time the header mounts, onAuthStateChanged attaches another subscription; without cleanup they pile up. onAuthStateChanged returns an unsubscribe function — call it from the effect's cleanup so the old subscription is torn down on unmount.

  • The cleanup function: whatever you return from a useEffect runs when the component unmounts (and before the effect re-runs). Returning () => unsubscribe() detaches the Firebase listener cleanly.
  • Why it matters: without it, navigating in and out of the header repeatedly registers duplicate listeners — wasted work and subtle double-fire bugs. This is the same lifecycle idea as componentWillUnmount in class components.
2.4

Hygiene: a constants file

Stop hard-coding strings (logo URLs, avatar URLs, API option objects) inline. Put them in a single utils/constants.js and import them where needed — change once, reflected everywhere.

// utils/constants.js
export const LOGO = "https://.../netflix-logo.png";
export const USER_AVATAR = "https://.../default-avatar.png";

export const API_OPTIONS = {
  method: "GET",
  headers: {
    accept: "application/json",
    Authorization: "Bearer <TMDB_ACCESS_TOKEN>",
  },
};
  • One place to change: if the logo or default avatar updates, you edit a single line instead of hunting copies across components. That's the whole payoff.
  • Watch the JSX braces: once a value is imported as a JS constant, use it directly (src={USER_AVATAR}) — a leftover wrapper like {`${USER_AVATAR}`} can produce an invalid URL (the real cause of a Firebase "invalid photoURL" 400 in this build).
2.5

Fetching real data from TMDB

Movie data comes from TMDB (The Movie Database) — register an app, get an access token, and read the docs to find the right endpoint. The "now playing" list is a GET call that needs your token passed in the fetch options.

const getNowPlayingMovies = async () => {
  const data = await fetch(
    "https://api.themoviedb.org/3/movie/now_playing?page=1",
    API_OPTIONS              // carries the Bearer token
  );
  const json = await data.json();
  // json.results = array of 20 movies
};
  • Auth on every call: TMDB rejects requests without the token. The API_OPTIONS object (with the Authorization: Bearer ... header) goes into every fetch — that's why it lives in constants.
  • Read the docs, don't guess: the endpoint, the GET method, and the sample request all come from TMDB's API reference. Being fluent at reading API docs is the transferable skill here.
  • The data shape: the response's results array holds the movies; each item has original_title, overview, vote_average, etc. That's what the UI will read.
Gotcha
Why the API fires twice in dev

CRA wraps the app in <React.StrictMode>, which intentionally double-invokes effects in development to surface impurities. It only happens locally, never in a production build — it's a feature, not a bug. (You can remove StrictMode while debugging, but keep it.)

2.6

A movies slice in the store

User data already lives in Redux; movie data deserves its own slice rather than being crammed into the user slice. Create a moviesSlice with an action per category, then dispatch the fetched results into it.

// utils/moviesSlice.js
const moviesSlice = createSlice({
  name: "movies",
  initialState: { nowPlayingMovies: null, popularMovies: null },
  reducers: {
    addNowPlayingMovies: (state, action) => {
      state.nowPlayingMovies = action.payload;
    },
    addPopularMovies: (state, action) => {
      state.popularMovies = action.payload;
    },
  },
});
  • Separate slice = clean state: the store stays organized — state.user for the person, state.movies for content. Each slice plugs into the store's reducer object independently.
  • Dispatch the results: after fetching, dispatch(addNowPlayingMovies(json.results)) writes the 20 movies into the store; components then read them with a selector.
2.7

Extracting a custom hook

"Fetch from TMDB → dispatch into the store" is logic, not UI. Pulling it into a custom hook keeps the Browse component to just render code. A hook is just a function whose name starts with use and that uses other hooks inside.

// hooks/useNowPlayingMovies.js
const useNowPlayingMovies = () => {
  const dispatch = useDispatch();
  useEffect(() => {
    const getNowPlayingMovies = async () => {
      const data = await fetch(NOW_PLAYING_URL, API_OPTIONS);
      const json = await data.json();
      dispatch(addNowPlayingMovies(json.results));
    };
    getNowPlayingMovies();
  }, []);
};
export default useNowPlayingMovies;
// Browse.js - now clean
const Browse = () => {
  useNowPlayingMovies();   // one line does the fetch + store update
  return (
    <div>
      <Header />
      <MainContainer />
      <SecondaryContainer />
    </div>
  );
};
  • Hooks live in their own folder: a /hooks directory keeps custom hooks separate from components and utils — predictable structure as the app grows.
  • The use prefix is required: it tells React (and the lint rules) this function may call hooks, so the Rules of Hooks apply. It's a convention with teeth, not just style.
  • Reuse by parameterizing: the same shape makes usePopularMovies (and top-rated, upcoming) trivial — swap the endpoint and the dispatch action. Each movie row gets its own hook.
2.8

Structuring the browse UI

Plan the layout before coding it. The browse page splits into two regions, and each breaks down further — so every piece becomes a small, focused component.

MainContainer

The hero: VideoBackground (trailer) + VideoTitle (name, overview, play buttons).

SecondaryContainer

Stacked MovieLists (Now Playing, Popular…), each a row of MovieCards.

Components read what they need from the store with useSelector. The hero only needs one movie, so it takes the first of the list — guarding against the brief moment before data arrives.

const MainContainer = () => {
  const movies = useSelector((store) => store.movies?.nowPlayingMovies);
  if (!movies) return null;        // early return: data not ready

  const mainMovie = movies[0];     // hero = first movie
  const { original_title, overview } = mainMovie;
  // pass title + overview down to VideoTitle
};
  • Optional chaining guards the read: store.movies?.nowPlayingMovies won't crash if movies is briefly undefined during the first render.
  • Early return for the empty state: if (!movies) return null skips rendering until the store is populated — otherwise movies[0] throws on null. A clean, common pattern.
  • Selectors connect UI to store: each container subscribes only to the slice it needs, so it re-renders when (and only when) that data changes.
2.9

Gotchas & nice-to-knows

Gotcha
useNavigate outside a Router

"may only be used in the context of a Router." The component must live inside the routes — use the header, not the router's parent.

Gotcha
Listener piles up

Re-mounting re-subscribes onAuthStateChanged. Return its unsubscribe from the effect to tear it down.

Gotcha
API called twice

StrictMode double-invokes effects in dev only. Don't "fix" it by deleting StrictMode in production code.

Gotcha
movies[0] crashes on load

The store is null before the fetch resolves. Guard with optional chaining + an early return.

Nice to know
Reading API docs is the skill

Endpoint, method, and auth header all come from TMDB's reference — learn to read docs rather than copy snippets blindly.

Nice to know
One hook per data source

Cloning the now-playing hook for each category keeps fetching logic out of components and trivially repeatable.

2.10

Interview Q&A

Q.How do you implement protected routes in a React app?
Check the auth state and redirect based on it. Here, a single onAuthStateChanged listener in the always-mounted header sends logged-out users to / and logged-in users to /browse. Centralizing it means every route is guarded automatically, with no per-component checks.
Q.Why does useNavigate throw "may only be used in the context of a Router"?
Because the component calling it sits outside the router's tree. Navigation hooks only work inside the routed components. Moving the logic into a component that's a child of the router (like the header) fixes it.
Q.What is a useEffect cleanup function and when does it run?
It's the function you return from the effect. React runs it when the component unmounts, and before the effect re-runs. You use it to undo side effects — here, calling Firebase's unsubscribe so the auth listener doesn't leak or duplicate.
Q.Why do effects and renders run twice in development?
React's StrictMode intentionally double-invokes effects and renders in development to help you catch impure logic and missing cleanups. It only happens locally — a production build runs them once. It's a safeguard, not a bug.
Q.What is a custom hook and why extract one here?
A custom hook is a reusable function (name starting with use) that bundles hook-based logic. Extracting the "fetch movies and dispatch to store" logic into useNowPlayingMovies keeps the Browse component to pure render code, and makes the same pattern trivial to reuse for other categories.
Q.Why keep hard-coded values in a constants file?
So you change them in one place. URLs, tokens, and config objects scattered inline are painful to update and easy to get inconsistent. A single constants file means one edit propagates everywhere it's imported.
Q.How do you avoid crashing when store data isn't loaded yet?
Guard the access. Use optional chaining (store.movies?.nowPlayingMovies) so the read is safe, and an early return (if (!movies) return null) so the component renders nothing until the data arrives — avoiding errors like reading index [0] of null.
Q.Why give movie data its own Redux slice?
To keep state organized by concern — user for the logged-in person, movies for content. Each slice has its own actions and reducer and plugs into the store independently, which scales better than overloading one slice.
protected routes = redirect on auth onAuthStateChanged listener put it in the always-mounted header navigate only inside the Router single source of truth for routing return unsubscribe from useEffect cleanup = componentWillUnmount constants file for URLs/tokens TMDB GET + Bearer in options json.results = movies StrictMode double-fires (dev only) moviesSlice, separate from user custom hook = use + logic /hooks folder one hook per category useSelector to read store optional chaining ?. early return null
Build 03

GPT Search — the Finale

A natural-language movie finder: prompt OpenAI, search each result on TMDB, render it — plus i18n, secrets, and memoization.

The headline feature is a pipeline: user prompt → OpenAI returns movie names → search each on TMDB → render. The React lessons live in the glue — a well-engineered prompt, parallel API calls with Promise.all, and keeping secrets out of the client.

3.1

The feature & how to toggle into it

GPT search is a separate view, not a bar crammed into the header. A button in the header flips between the normal browse page and the search page. The on/off flag lives in Redux — consistent with everything else in this app, which uses the store instead of local useState.

// gptSlice.js - just a toggle
const gptSlice = createSlice({
  name: "gpt",
  initialState: { showGptSearch: false },
  reducers: {
    toggleGptSearchView: (state) => {
      state.showGptSearch = !state.showGptSearch;
    },
  },
});
// Browse.js - conditional render
const showGptSearch = useSelector((s) => s.gpt.showGptSearch);

return showGptSearch ? <GptSearch /> : (
  <>
    <MainContainer />
    <SecondaryContainer />
  </>
);
  • Why a new slice: user data, movie data, and GPT state are separate concerns, so each gets its own slice. Logical separation keeps the store readable as features pile up.
  • Fragment for the either/or: JSX needs one parent, so the two-component branch is wrapped in a <>...</> fragment — no extra DOM node.
  • The header button doubles as the back link: its label flips too (GPT Search ↔ Home) by reading the same flag, so one button drives the whole toggle.
3.2

Bonus: making the app multilingual

A small, high-impact addition: drive every visible string from a language config instead of hard-coding it, then let a dropdown switch the active language. Store the chosen language in Redux and read it in each component.

// utils/languageConstants.js
const lang = {
  en: { search: "Search", gptSearchPlaceholder: "What would you like to watch today?" },
  hindi: { search: "खोजें", gptSearchPlaceholder: "आज आप क्या देखना चाहेंगे?" },
  spanish: { search: "Buscar", gptSearchPlaceholder: "¿Qué te gustaría ver hoy?" },
};
export default lang;
// read the active language from the store, then index in
const langKey = useSelector((s) => s.config.lang);
<button>{lang[langKey].search}</button>
  • It's the constants rule, scaled up: the same "no hard-coded strings" habit becomes localization — one config object per language, indexed by a key.
  • Supported-languages list is also a constant: the dropdown options come from an array of { identifier, name } so adding a language is one entry, not edits in two places.
  • Why it's worth it: a multilingual toggle is a strong portfolio/interview signal and helps SEO — cheap to add once strings are externalized.
3.3

Wiring up OpenAI

Get an API key from the OpenAI platform, install the openai package, and initialize a client in its own helper file (like the Firebase setup). The chat completions endpoint takes a model and a list of messages.

// utils/openai.js
import OpenAI from "openai";

const openai = new OpenAI({
  apiKey: process.env.REACT_APP_OPENAI_KEY,
  dangerouslyAllowBrowser: true,   // see 3.6 - not for production
});
export default openai;
  • Helper file pattern: third-party setup (client init + auth) lives in its own utils file, keeping components free of library plumbing.
  • Pick the cheap model: use gpt-3.5-turbo unless you truly need GPT-4 — these calls are billed, unlike the free TMDB and Firebase tiers.
  • Billing reality: the key only works once a billing method is set; a free trial credit may apply first. Treat usage as metered.
3.4

Prompt engineering: ask for a parseable answer

A raw query ("funny Indian retro movies") gives chatty, unpredictable output. Wrap the user's text in a precise instruction that fixes the role, the count, and the exact format — so the response is easy to parse.

const query =
  "Act as a movie recommendation system and suggest some movies for " +
  "the query: " + searchText.current.value +
  ". Only give me names of 5 movies, comma-separated, " +
  "like the example result given ahead: " +
  "Gadar, Sholay, Don, Golmaal, Koi Mil Gaya";

const gptResults = await openai.chat.completions.create({
  model: "gpt-3.5-turbo",
  messages: [{ role: "user", content: query }],
});

// the model replies with one comma-separated string
const gptMovies = gptResults.choices[0].message.content.split(",");
  • Constrain the output: stating the count and "comma-separated, like this example" turns free-form prose into a string you can split(",") into an array. The example is the key — it shows the model the shape.
  • Read the response carefully: the text is at choices[0].message.content. Guard it — if choices is missing, show an error rather than crashing (real error handling left as a TODO in the build).
  • Garbage in, garbage out: the model is only as good as the prompt. Specific role + format + example beats a bare query every time.
Analogy · recall

Prompting GPT is like ordering at a deli with a picky order. "Give me a sandwich" gets you something random; "five turkey subs, no mayo, cut in half, like the one in the photo" gets you exactly what you can use. The example in the prompt is the photo — it removes all guesswork about the format.

3.5

Searching TMDB for each title, in parallel

GPT returns five names, but we need real movie objects (posters, overviews) from TMDB's search endpoint. Map each name to a search call — which gives an array of promises, not results — then await them all together with Promise.all.

const searchMovieTMDB = async (movie) => {
  const data = await fetch(
    "https://api.themoviedb.org/3/search/movie?query=" +
      movie + "&include_adult=false&page=1",
    API_OPTIONS
  );
  const json = await data.json();
  return json.results;
};

// map gives an ARRAY OF PROMISES (calls fire in parallel)
const promiseArray = gptMovies.map((movie) => searchMovieTMDB(movie));

// Promise.all waits for every call to resolve
const tmdbResults = await Promise.all(promiseArray);
  • map + async = array of promises: an async function always returns a promise, so gptMovies.map(searchMovieTMDB) produces five promises immediately — the calls don't wait for each other.
  • Promise.all joins them: it takes the array of promises and resolves once all finish, handing back an array of results in order. This is the single most-missed concept in interviews per the build.
  • Parallel, not sequential: firing all five at once and awaiting together is far faster than awaiting each in a loop. That's the reason to reach for Promise.all.
  • Each search may return several matches: a title can exist in multiple languages, so you get an array-of-arrays — show all, or filter to exact-title matches.
3.6

Keeping the secret out of the client

OpenAI throws unless you pass dangerouslyAllowBrowser: true — on purpose. Calling it from the browser exposes your key, and an OpenAI key drives real billing. The warning is the SDK protecting you.

Client-side (this build)

Quick to demo, but the key ships in the bundle. Acceptable only for a throwaway/learning project — revoke the key after.

vs
Server-side (correct)

Put the key and the OpenAI call on a backend (e.g. Node). The browser calls your server; the secret never leaves it.

  • Two tiers of secrecy: the TMDB key is free, so a leak just hits a rate limit. The OpenAI key spends money — treat it as a true secret.
  • At minimum, use .env: store keys in a .env file (CRA needs the REACT_APP_ prefix) and add it to .gitignore so they're never committed. Note this still bundles them client-side — it hides them from git, not from the browser.
  • The real fix is a backend: dangerouslyAllowBrowser is a learning shortcut. Production work proxies the call through a server.
3.7

Rendering results by reusing components

Push the TMDB results into the GPT slice, then render them with the same MovieList component the browse page already uses. No new card code — just feed the existing component different data.

// store the results, then read them back
dispatch(addGptMovieResult({ movieNames: gptMovies, movieResults: tmdbResults }));

// GptMovieSuggestions.js - reuse MovieList per result row
{movieNames.map((name, i) => (
  <MovieList key={name} title={name} movies={movieResults[i]} />
))}
  • Reuse beats rewrite: the browse page's MovieList already renders a titled row of cards, so the suggestions page just passes it the GPT results. Component reuse is the payoff of clean structure.
  • Store the names too: keeping both the GPT names and the TMDB results in the slice lets each suggestion row show the queried title alongside its matches.
  • Show feedback while waiting: the GPT + 5 TMDB calls take a moment — a shimmer/loader keeps the UI honest during the async gap.
3.8

Memoization & final polish

Performance
Memoization

Cache expensive computed values (and avoid needless re-renders/re-fetches) so the same work isn't repeated when state elsewhere changes.

UX
Responsive layout

Make it work on phone, tablet, and desktop with responsive utility classes — the browse and search views both adapt.

Polish
Title & favicon

Set the page <title> and favicon in public/index.html — small touches that make it feel like a real product.

Robustness
Error handling

Guard the GPT response (missing choices) and surface a graceful message instead of letting the component throw.

  • Memoize the expensive, not everything: reach for it when a value is costly to compute or a child re-renders too often — not as a blanket habit.
  • Responsiveness is a feature: a layout that breaks on mobile undercuts the whole project; test the small screens explicitly.
3.9

Interview Q&A

Q.How would you integrate an LLM API like OpenAI into a React app?
Initialize the client in a helper file with your API key, then call the chat completions endpoint with a model and a messages array. Wrap the user's input in a precise prompt, await the response, and parse the result. Ideally the call goes through a backend so the key stays secret.
Q.Why is prompt engineering important when calling GPT programmatically?
Because the output's quality and shape depend entirely on the prompt. Specifying the role, the exact count, the format (e.g. comma-separated), and giving an example turns chatty prose into a predictable string you can reliably parse into data.
Q.What does Promise.all do and why use it here?
It takes an array of promises and resolves once all of them complete, returning their results in order. Mapping movie names to async search calls produces an array of promises that fire in parallel; Promise.all waits for all five and gives back the results together — faster than awaiting each in sequence.
Q.Why does mapping an async function give promises instead of values?
An async function always returns a promise. So array.map(asyncFn) returns an array of promises that haven't resolved yet — the calls start but don't block. You then await them (e.g. with Promise.all) to get the actual values.
Q.Why is calling OpenAI from the browser a problem, and how do you fix it?
The API key ships in the client bundle, so anyone can steal it and run up your bill. OpenAI even requires dangerouslyAllowBrowser: true to allow it. The fix is to make the call from a backend so the key never reaches the browser; at minimum keep it in a gitignored .env.
Q.How do you make a React app multilingual?
Externalize every visible string into a language config object keyed by language. Store the selected language (e.g. in Redux), and have components read the right string by indexing the config with that key. A dropdown changes the stored language and the UI re-renders in it.
Q.How did you display the GPT results without writing new UI?
By reusing the existing MovieList component from the browse page. The GPT results are stored in the slice, then each result row is rendered by passing it to MovieList with a title and the movie array — component reuse instead of duplicate card code.
Q.When should you reach for memoization?
When a value is expensive to compute or a component re-renders more than it needs to. Memoization caches the result so the work isn't repeated on unrelated state changes. It's a targeted optimization, not something to apply everywhere by default.
GPT search = separate view gptSlice toggle in Redux conditional render + fragment i18n via language config lang stored in store openai client in helper file gpt-3.5-turbo (cheaper) prompt: role + count + format + example choices[0].message.content .split(",") into array map → array of promises Promise.all to await all parallel TMDB searches dangerouslyAllowBrowser (not prod) .env + .gitignore for keys secrets belong on a backend reuse MovieList component memoize the expensive responsive layout