0% of stages checked
Senior Frontend Interview Prep · First Principles

Music Explorer

Build a Discogs-powered music app one layer at a time, going from zero React to interview-solid. The stages build on each other: each new idea is introduced only after you have felt the problem it solves, and every Deeper intuition bridges from the stage before it. A concrete run-trace and a Senior interview lens come with each one.

How to use this workbook

The challenge sounds like one application, but it is actually a bundle of smaller ideas:

external data -> JavaScript data -> transformed data -> React UI -> user event -> state change -> re-render

The curriculum therefore grows the app in layers. Each stage contains:

  1. Why this stage exists - the motivating problem.
  2. First-principles mental model - what the student should be able to picture.
  3. Build target - the smallest useful task.
  4. Interview prep notes - what an interviewer may probe.
  5. 2-3 bugs to watch for - mistakes worth learning deliberately.
  6. Dilution / scope guard - what not to introduce yet, so the concept stays visible.
  7. Checkpoint solution - a complete solution for the stage.
  8. Exit test - what the student should explain without reading notes.

The student should not memorize completed code. The final code in each stage is an answer key after an attempt.

Stage 0 · Foundations
STAGE 0

Set Up the Smallest Useful React Environment

Before learning React, the student needs a stable place where editing a file visibly changes the browser. Tooling should disappear into the background.
Why this stage, and what to picture

Before learning React, the student needs a stable place where editing a file visibly changes the browser. Tooling should disappear into the background.

The goal is not “learn Vite.” The goal is:

edit source -> dev server rebuilds -> browser shows result

Vite currently provides a React + TypeScript template directly. Current Vite documentation requires Node 20.19+ or 22.12+; a newer Node release is also fine if the template and packages support it.

There are three different things here:

Node/npm Vite React --------- -------- -------- runs tools serves/builds describes UI installs deps source code and behavior

React is not the web server. Vite is not React. npm is not React either.

Deeper intuition — builds on the last stage

Nothing about React yet: this stage is just setting up the workshop. Three tools, three jobs. Node runs JavaScript on your machine, Vite bundles your code and serves it while you work, and React (next stage) describes the screen. Keeping those roles separate is the first thing that prevents confusion later.

The one loop to internalize: edit a file → Vite rebuilds → the browser shows it. In production Vite is gone; it has already compiled everything to plain files the browser reads directly. So Node and Vite are build-time, React is run-time. That split explains a whole class of “works locally, breaks live” bugs.

How it runs in this exercise: call → render → paint

save App.tsx → Vite dev server sees the change and recompiles → pushes a hot-update to the browser over a websocket → React re-runs App() and swaps the changed DOM → the browser repaints (no user events or state yet — the file save is the trigger)
Senior interview lens

Senior interviewers probe the build-versus-runtime boundary. Be able to say exactly what reaches the user: static JS, CSS, and HTML, produced by bundling and tree-shaking your module graph. The dev server, hot reload, and source maps are development-only. It is also why import.meta.env values are inlined at build time — the ground truth behind “why did my secret leak?”

Target design — how the app should look after this stage

Just proof the pipeline works: your own text on screen, hot-reloading as you edit.

localhost:5173

Music Explorer

The Vite starter, replaced by your own heading. Edit App.tsx and this updates instantly.

Build target

Create a React + TypeScript Vite app and prove that changing App.tsx changes the page.

Commands

npm create vite@latest music-explorer -- --template react-ts cd music-explorer npm install npm run dev
Interview prep

Answer each out loud first, then open to check.

Q What is Vite doing for you?

“During development it serves the app and provides fast module updates. For production it builds optimized static assets. React is the UI library; Vite is the build/dev tool.”

Q Why TypeScript?

“The API gives us structured data. Types make the expected shape explicit and catch mismatches earlier, especially around optional fields and nested API responses.”

Bugs to watch for
Bug Running npm run dev from the wrong directory.

The terminal must be inside music-explorer.

Bug Editing the wrong file.

Vite’s React template renders src/App.tsx from src/main.tsx.

Bug Confusing a warning with a runtime failure.

Read the first actual error and the file/line that caused it.

Scope guard — what NOT to add yet

Do not install Tailwind, React Router, TanStack Query, Axios, Redux, or a component library yet.

At this point, extra packages dilute the only idea that matters: source code becomes UI.

Checkpoint solution

An answer key after your own attempt — never a thing to copy first.

Solution Full checkpoint code

src/App.tsx

export default function App() { return <h1>Music Explorer</h1>; }

src/main.tsx

import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import './index.css'; import App from './App'; createRoot(document.getElementById('root')!).render( <StrictMode> <App /> </StrictMode>, );
Exit test

The student can answer:

  • What starts the development server?
  • What file renders <App />?
  • What is React responsible for versus Vite?
Reflection

Answer each in your own words first. If you can, the idea is yours.

Reflect If you deleted Vite after building for production, would the app still run in a browser? Why?

Yes. Vite is a build-time tool; by production it has already compiled everything to static JS, CSS, and HTML the browser runs on its own. Only development needs Vite running.

Reflect A teammate says “React is the web server.” How do you correct them?

React is a run-time UI library that runs in the browser (or renders HTML on a server). It is not a web server. In dev, Vite serves files and Node runs tooling; React only describes the UI.

Stage 1 · Foundations
STAGE 1

Render One Static Album Card

A React app ultimately produces HTML-like UI. Before adding state or data structures, the student should be able to look at a design and turn it into semantic markup.
Why this stage, and what to picture

A React app ultimately produces HTML-like UI. Before adding state or data structures, the student should be able to look at a design and turn it into semantic markup.

The motivating question is:

If there were only one album in the entire application, what HTML would we need to display it?

JSX description -> React -> DOM -> pixels

JSX is a convenient syntax for describing a UI tree. It is not a separate webpage language.

Deeper intuition — builds on the last stage

Here is the first real React idea. You write JSX, which looks like HTML, but it is not HTML. It is JavaScript that produces a plain object describing what you want on screen. React reads that description and builds the actual page for you.

So picture two worlds: your description (cheap, just objects) and the real DOM the browser paints (expensive). You will meet this split again and again. Right now the description is fixed, written by hand. Everything from here on is about where that description comes from, and when it changes.

How it runs in this exercise: call → render → paint

browser loads index.html → runs src/main.tsx → createRoot(#root).render(<App/>) → React calls App() once ← the mount render → App returns JSX = createElement(...) objects (a description) → React builds the real DOM nodes from that description → the browser paints (one render, at mount; no events, no state)
Senior interview lens

The senior framing is a pipeline: JSX → createElement → lightweight element objects → reconciliation → DOM. Keep three things distinct: an element (a cheap description), a component (a function), and the DOM (the expensive real thing). Once you see elements are just objects, “re-rendering is cheap; committing to the DOM is what costs” becomes something you can reason about, not a slogan.

Target design — how the app should look after this stage

One album card, hand-written in JSX with plain CSS. No data, no state yet.

localhost:5173
cover
Purple Rain
Prince
1984
Build target

Render one album with a title, artist, year, and a visual placeholder.

Interview prep

Answer each out loud first, then open to check.

Q Is JSX HTML?

“It resembles HTML, but it is JavaScript syntax that is transformed into React element descriptions. That is why we use className, expressions in braces, and JavaScript rules.”

Q Why return one parent element?

“A component returns a single React value/tree. Multiple siblings can be wrapped in a parent element or Fragment.”

Bugs to watch for
Bug Using class instead of React’s className.

Using class instead of React’s className.

Bug Forgetting to close JSX tags such as <img />.

Forgetting to close JSX tags such as <img />.

Bug Returning adjacent top-level elements without a wrapper or Fragment.

Returning adjacent top-level elements without a wrapper or Fragment.

Scope guard — what NOT to add yet

Use plain CSS. Do not introduce Tailwind yet. The student should see the direct relationship between a CSS rule and a DOM element.

Checkpoint solution

An answer key after your own attempt — never a thing to copy first.

Solution Full checkpoint code

src/App.tsx

import './App.css'; export default function App() { return ( <main className="page"> <article className="album-card"> <div className="cover-placeholder">Cover</div> <h2>Purple Rain</h2> <p>Prince</p> <p>1984</p> </article> </main> ); }

src/App.css

.page { padding: 24px; font-family: system-ui, sans-serif; } .album-card { width: 220px; padding: 16px; border: 1px solid #ddd; border-radius: 12px; } .cover-placeholder { display: grid; place-items: center; aspect-ratio: 1; background: #eee; border-radius: 8px; }
Exit test

The student can draw the DOM tree:

main └── article ├── div ├── h2 ├── p └── p
Reflection

Answer each in your own words first. If you can, the idea is yours.

Reflect Someone calls JSX “HTML inside JavaScript.” How would you make that precise?

JSX is JavaScript syntax that compiles to createElement calls returning plain objects — a description of UI. It only resembles HTML, which is why it is className, why expressions go in braces, and why the browser never sees the JSX itself.

Reflect Why must a component return a single parent element or a Fragment?

A component returns one JavaScript value describing one tree. Two adjacent elements are two values; wrap them in one parent or a <>…</> Fragment so there is a single root.

Stage 2 · Foundations
STAGE 2

Move Album Information into Data

Hardcoded UI works for one album, but it mixes what the album is with how an album looks.
Why this stage, and what to picture

Hardcoded UI works for one album, but it mixes what the album is with how an album looks.

We want to establish one of the most important frontend ideas:

UI should be produced from data.

album object | | read properties v JSX | v screen

The UI is no longer the source of truth for the title. The object is.

Deeper intuition — builds on the last stage

In Stage 1 the card’s text was written straight into the JSX. Now move it into a plain object and read from it. Same screen, but the truth has moved: the object is the source of truth, and the JSX just displays it.

This is the seed of the whole framework, worth stating as a formula: UI = f(data). The screen is a function of your data. Change the data, re-run, and the screen follows; you never hand-edit the page. The data is still fixed for now. Stage 5 is where it starts to change.

How it runs in this exercise: call → render → paint

mount → React calls App() → App reads the album object, returns JSX with {album.title} ... → React builds the DOM → the browser paints (still one render; the object is READ during render, not "watched". editing the object in source triggers a dev hot-update, not a state render)
Senior interview lens

UI = f(state) is the sentence behind almost every strong React answer. A senior treats the job as: keep the state set as small as possible, derive everything else, and keep rendering a pure projection. The moment you reach for a manual DOM mutation, you have stepped outside the model — interviewers listen for exactly that tell.

Target design — how the app should look after this stage

Identical pixels, but the title / artist / year now come from a data object, not hardcoded JSX.

localhost:5173
cover
Purple Rain
Prince
1984

Same look; the object is now the source of truth.

Build target

Represent one album as a JavaScript object and render its properties.

Interview prep

Answer each out loud first, then open to check.

Q Why put related fields in an object?

“They describe one entity and usually travel together. It lets us pass, transform, type, and render the album as one unit.”

Q What happens when the object value changes?

“A normal local object changing does not automatically cause React to re-render. At this stage the object is static. Later, state will handle changing values.”

Bugs to watch for
Bug Writing {album} directly in JSX

React cannot render an arbitrary object as text.

Bug Misspelling a property such as album.artst and getting undefined.

Misspelling a property such as album.artst and getting undefined.

Bug Assuming mutating a normal object automatically triggers a React render.

Assuming mutating a normal object automatically triggers a React render.

Scope guard — what NOT to add yet

No useState yet. The only idea is data -> UI.

Checkpoint solution

An answer key after your own attempt — never a thing to copy first.

Solution Full checkpoint code

src/App.tsx

import './App.css'; const album = { id: 1, title: 'Purple Rain', artist: 'Prince', year: 1984, }; export default function App() { return ( <main className="page"> <article className="album-card"> <div className="cover-placeholder">Cover</div> <h2>{album.title}</h2> <p>{album.artist}</p> <p>{album.year}</p> </article> </main> ); }

Use the same App.css from Stage 1.

Alternative solution — flexbox instead of grid

The card’s cover placeholder centers the word “Cover” using CSS grid (place-items:center). Flexbox reaches the exact same look a different way. Two tools, one result: the design is the target, not the technique.

Flex Same card, centered with flexbox

Grid version (from the checkpoint):

.cover-placeholder { display: grid; place-items: center; /* centers on BOTH axes in one line */ aspect-ratio: 1; background: #eee; border-radius: 8px; }

Flexbox alternative — identical result:

.cover-placeholder { display: flex; align-items: center; /* center on the cross axis */ justify-content: center; /* center on the main axis */ aspect-ratio: 1; background: #eee; border-radius: 8px; }
What to noticeGrid’s place-items:center centers vertically and horizontally in one property. Flex needs two: align-items for the cross axis and justify-content for the main axis. The pixels are the same — pick whichever reads clearer to you.
Exit test

Ask: “If I change album.title before render, why does the screen change? Is React state involved?”

Expected answer: the next render reads a different value; state is not involved yet.

Reflection

Answer each in your own words first. If you can, the idea is yours.

Reflect State the relationship between your data and the screen in one line, and say what it buys you.

UI = f(data): the screen is a function of the data. It means you never hand-edit the DOM — you change data and re-render, and the screen stays consistent by construction.

Reflect You mutate the album object and nothing re-renders. Bug, or expected?

Expected at this stage. The object is plain data read during render; nothing is watching it. Re-rendering on change is exactly what state (Stage 5) will add.

Stage 3 · Foundations
STAGE 3

Extract AlbumCard and Learn Props

We know how one album looks. Now we want a reusable rule:
Why this stage, and what to picture

We know how one album looks. Now we want a reusable rule:

Given an album, display an album card.

That is what a component gives us.

A component can be treated like a function:

Album data -> AlbumCard -> JSX

Props are simply the inputs to that component.

parent owns data | | props v child renders data
Deeper intuition — builds on the last stage

You can build one card from one object. Now wrap that rule, “given an album, show a card,” in a reusable function: a component. The album it needs arrives as props, which are simply the function’s arguments.

Two rules that never change: data flows down (parent to child), and a child never edits what it was handed. If something must change, the owner above changes its own data and re-renders, sending fresh props down. That one-way flow is what lets you trace any pixel on screen back to the single place that owns its data.

How it runs in this exercise: call → render → paint

mount → App() runs → returns <AlbumCard album={album} /> → React calls AlbumCard(props) ← props are the function's arguments → AlbumCard returns JSX → React builds DOM → the browser paints (one render; data flows downward as props)
Senior interview lens

This is really about where state lives. Keep it as low as possible; lift it only to the closest common ancestor that needs it (“lift state up,” “colocation”). Prop-drilling pain is the honest motivation for Context or composition — but reach for those only when drilling actually hurts, and be ready to weigh Context’s re-render cost against it.

Target design — how the app should look after this stage

Still one card, now produced by a reusable component fed props by the parent.

localhost:5173
cover
Purple Rain
Prince
1984

One <AlbumCard />, ready to be reused for many albums.

Build target

Create an AlbumCard component and pass album information to it.

Interview prep

Answer each out loud first, then open to check.

Q What is the difference between props and state?

“Props are inputs supplied by the parent. State is memory owned by a component instance. Props are read-only from the receiving component’s perspective.”

Q Why extract a component?

“Because the same UI rule will be repeated for many albums and should have one implementation.”

Bugs to watch for
Bug Calling a component as AlbumCard() instead of rendering `<AlbumCard ..

/>` in ordinary component composition.

Bug Mutating props inside the child.

Mutating props inside the child.

Bug Passing year as a string sometimes and a number other times without a consistent type.

Passing year as a string sometimes and a number other times without a consistent type.

Scope guard — what NOT to add yet

Do not add context or global state. Props are enough.

Scaffolding dilution

Instructor supplies the component signature. Student should write the JSX body and parent usage.

Checkpoint solution

An answer key after your own attempt — never a thing to copy first.

Solution Full checkpoint code

src/components/AlbumCard.tsx

type AlbumCardProps = { title: string; artist: string; year: number; }; export default function AlbumCard({ title, artist, year }: AlbumCardProps) { return ( <article className="album-card"> <div className="cover-placeholder">Cover</div> <h2>{title}</h2> <p>{artist}</p> <p>{year}</p> </article> ); }

src/App.tsx

import './App.css'; import AlbumCard from './components/AlbumCard'; const album = { id: 1, title: 'Purple Rain', artist: 'Prince', year: 1984, }; export default function App() { return ( <main className="page"> <AlbumCard title={album.title} artist={album.artist} year={album.year} /> </main> ); }
Exit test

The student can explain where the data lives and which direction it moves.

Reflection

Answer each in your own words first. If you can, the idea is yours.

Reflect A child needs to change a value it got via props. What is the correct move, and why not edit the prop?

Props are read-only and flow down. Lift the change to the owner above: it updates its own state and re-renders, passing fresh props down. Editing a prop breaks one-way flow and the ability to trace where data comes from.

Reflect Why extract AlbumCard instead of inlining the JSX?

The same “given an album, show a card” rule will run for many albums. One component means one implementation to reason about, test, and change.

Stage 4 · Lists & State
STAGE 4

Render a Collection with Arrays and map

The challenge is not “show an album.” It is “show a collection.” A collection is naturally represented as an array.
Why this stage, and what to picture

The challenge is not “show an album.” It is “show a collection.” A collection is naturally represented as an array.

We want the student to see map as a transformation, not a React incantation.

albums[] | | for each album, produce an AlbumCard v JSX[]

map means:

take every item and transform it into something else.

React simply knows how to render the resulting list of elements.

Deeper intuition — builds on the last stage

One album was the warm-up; a real app shows a collection, and a collection is an array. array.map turns each album into a card, so a list of data becomes a list of UI. map is a plain transformation you already know, not React magic.

The genuinely new idea is the key. Across renders React lines up the old list against the new one and needs to know “which card is which.” The key answers that. Use a stable id, never the array position, because position shifts the moment the list is filtered or reordered. Hold onto “identity across renders” — it matters the instant the list can change.

How it runs in this exercise: call → render → paint

mount → App() runs → albums.map(a => <AlbumCard key={a.id} .../>) runs DURING render → produces an array of element descriptions → React calls AlbumCard once per item, builds a keyed list of DOM nodes → the browser paints the grid (one render; map is a plain function call inside render)
Senior interview lens

Keys drive reconciliation. React matches siblings by key; an index key binds state to the wrong row when the list changes — a checkbox “jumps,” an input loses focus mid-type. Describing that concrete corruption, not just “keys must be unique,” is the senior signal. Identity is not position.

Target design — how the app should look after this stage

A collection: three albums rendered by mapping an array to s in a grid.

localhost:5173

Music Explorer

cover
Purple Rain
Prince
1984
cover
Thriller
Michael Jackson
1982
cover
Rumours
Fleetwood Mac
1977
Build target

Render three fake albums using .map().

Interview prep

Answer each out loud first, then open to check.

Q Why does React need a key?

“Keys identify list items across renders so React can match old and new children correctly. A stable domain ID is better than the array index when order can change.”

Q Does .map() mutate the original array?

No. It creates a new array from the callback results.

Bugs to watch for
Bug Forgetting return when using braces in a map callback.

Forgetting return when using braces in a map callback.

Bug Using key={index} for reorderable/filterable data when a stable id exists.

Using key={index} for reorderable/filterable data when a stable id exists.

Bug Putting the key inside AlbumCard rather than on the element created by the parent list.

Putting the key inside AlbumCard rather than on the element created by the parent list.

Scope guard — what NOT to add yet

No search, no API, no state. Master list rendering first.

Scaffolding dilution

Instructor provides the data array. Student writes the map and explains the callback for one item.

Checkpoint solution

An answer key after your own attempt — never a thing to copy first.

Solution Full checkpoint code

src/App.tsx

import './App.css'; import AlbumCard from './components/AlbumCard'; const albums = [ { id: 1, title: 'Purple Rain', artist: 'Prince', year: 1984 }, { id: 2, title: 'Thriller', artist: 'Michael Jackson', year: 1982 }, { id: 3, title: 'Rumours', artist: 'Fleetwood Mac', year: 1977 }, ]; export default function App() { return ( <main className="page"> <h1>Music Explorer</h1> <section className="album-grid"> {albums.map((album) => ( <AlbumCard key={album.id} title={album.title} artist={album.artist} year={album.year} /> ))} </section> </main> ); }

Add to src/App.css

.album-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; }
Alternative solution — flexbox instead of grid

The collection uses CSS grid with auto-fill + minmax. Flexbox with flex-wrap can produce the same responsive gallery. Same design, a different tool — with one real tradeoff worth knowing.

Flex Same gallery, laid out with flexbox

Grid version (from the checkpoint):

.album-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; }

Flexbox alternative:

.album-grid { display: flex; flex-wrap: wrap; gap: 16px; } .album-grid > * { flex: 1 1 220px; /* grow, shrink, ~220px ideal width */ }
The tradeoff (interview-worthy)Both wrap into a responsive gallery. The difference: grid’s auto-fill keeps every column the same width, so a short last row still lines up. With flex, flex-grow stretches the last row’s items to fill the space, so they can end up wider than the rows above. Rule of thumb: grid for two-dimensional, aligned layouts; flex for one-dimensional rows. Either one hits this design.
Exit test

Give the student one album object and ask them to manually show what the map callback returns for that one object.

Reflection

Answer each in your own words first. If you can, the idea is yours.

Reflect Explain, with a concrete failure, why key={index} is risky.

Keys are identity across renders. With index keys, deleting or reordering an item makes React reuse the wrong DOM node — a checkbox stays checked on the wrong row, an input keeps the wrong text. A stable id avoids it because identity is not position.

Reflect Is map doing anything React-specific here?

No. map is a plain array transformation from data to elements; React just renders the resulting array. The React-specific part is the key.

Stage 5 · Lists & State
STAGE 5

Introduce State with a Controlled Search Input

So far the UI is a pure display. The challenge requires interaction. The first user-controlled value is the search text.
Why this stage, and what to picture

So far the UI is a pure display. The challenge requires interaction. The first user-controlled value is the search text.

A normal variable is not enough because React needs to:

  1. remember the value between renders, and
  2. re-render when that value changes.

That is exactly what React state provides.

React’s documentation describes state as a component’s memory.

user types | v browser input event | v onChange handler | v setSearch(newValue) | v React stores state + schedules render | v component runs again with new search

This is the central React loop.

Deeper intuition — builds on the last stage

Every stage so far used fixed data. The app becomes interactive the moment data can change over time, and that changing data is state. A normal variable cannot do the job: it resets on every render, and changing it does not tell React to redraw. useState gives you a value that survives renders plus a setter that asks React to redraw.

So Stage 2’s UI = f(data) becomes UI = f(state), and here is the loop you will use forever: you type → onChange runs → setSearch updates state → React re-runs your component → the new value appears. Your letters take a round trip through React. That is why a controlled input needs both value (from state) and onChange (to state); with only value, state never changes and the box looks frozen.

How it runs in this exercise: call → render → paint

you press a key → the browser fires an input event on <input> → React runs your onChange handler → onChange calls setSearch(e.target.value) → React marks App dirty and SCHEDULES a render → React re-runs App() with the new search value → React updates the input's DOM value → the browser paints the new character (every keystroke is one full trip through React)
Senior interview lens

State is a snapshot: each render closes over its own search. That one fact explains stale-closure bugs (a setInterval that logs the old value) and the updater form. A senior also knows controlled vs uncontrolled, and when uncontrolled inputs or refs are the right call — very large forms, or wrapping a non-React widget.

Target design — how the app should look after this stage

A controlled search box appears. It stores what you type but does not filter the list yet.

localhost:5173

Music Explorer

Current search: (empty)

cover
Purple Rain
Prince
1984
cover
Thriller
Michael Jackson
1982
cover
Rumours
Fleetwood Mac
1977
Build target

Add a controlled input whose current value is stored in search state. Do not filter yet.

Interview prep

Answer each out loud first, then open to check.

Q Why doesn’t let search = '' work?

“Local variables are recreated on render and assigning to them does not notify React that the UI needs another render. useState preserves the value and its setter schedules a render.”

Q What makes an input controlled?

“React state is the source of truth for value, and changes flow through an event handler that updates that state.”

Q Is setSearch synchronous?

“It requests a state update. The current render’s search value does not mutate in place. React processes the update and renders again.”

Bugs to watch for
Bug onChange={setSearch(event.target.value)} - calling the setter during render instead of passing a function.

onChange={setSearch(event.target.value)} - calling the setter during render instead of passing a function.

Bug Forgetting value={search}, accidentally creating an uncontrolled input while expecting React state to control it.

Forgetting value={search}, accidentally creating an uncontrolled input while expecting React state to control it.

Bug Logging search immediately after setSearch(...) and expecting the same render’s variable to have changed.

Logging search immediately after setSearch(...) and expecting the same render’s variable to have changed.

Scope guard — what NOT to add yet

Do not filter yet. The student should be able to trace the input event/state/render cycle without another concept layered on top.

Scaffolding dilution

Instructor asks “what changes over time?” Student should identify search and write the state declaration.

Checkpoint solution

An answer key after your own attempt — never a thing to copy first.

Solution Full checkpoint code

src/App.tsx

import { useState } from 'react'; import './App.css'; import AlbumCard from './components/AlbumCard'; const albums = [ { id: 1, title: 'Purple Rain', artist: 'Prince', year: 1984 }, { id: 2, title: 'Thriller', artist: 'Michael Jackson', year: 1982 }, { id: 3, title: 'Rumours', artist: 'Fleetwood Mac', year: 1977 }, ]; export default function App() { const [search, setSearch] = useState(''); return ( <main className="page"> <h1>Music Explorer</h1> <label> Search <input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Album or artist" /> </label> <p>Current search: {search || '(empty)'}</p> <section className="album-grid"> {albums.map((album) => ( <AlbumCard key={album.id} title={album.title} artist={album.artist} year={album.year} /> ))} </section> </main> ); }
Exit test

Student draws the full sequence from keyboard event to re-render and can explain why the setter is passed a function in onChange.

Reflection

Answer each in your own words first. If you can, the idea is yours.

Reflect Trace the path from a keystroke to the new letter appearing on screen.

Keystroke → browser input event → onChangesetSearch updates state → React schedules a re-render → the component re-runs with the new value → React updates the input’s DOM value → paint. The letter round-trips through React.

Reflect Why won’t let search = '' work instead of useState?

A local variable resets every render, and changing it does not tell React to re-render. useState persists the value across renders and its setter schedules the render.

Reflect Name the two halves of a controlled input. What breaks if you omit onChange?

value (from state) and onChange (to state). Without onChange, state never updates, so every re-render shows the old value and the box looks frozen.

Stage 6 · Lists & State
STAGE 6

Derive Filtered Albums Instead of Duplicating State

Now the search value should affect what we display.
Why this stage, and what to picture

Now the search value should affect what we display.

A common beginner mistake is to create another state variable for filteredAlbums. That creates two sources of truth.

The first-principles question is:

Can filteredAlbums be calculated from values we already have?

Yes:

albums + search -> filteredAlbums

Therefore it is derived data, not independent state.

Every render is a fresh calculation:

current albums + current search | v filter() | v current visible albums

No effect is needed.

Deeper intuition — builds on the last stage

You now hold two pieces of truth: the full albums list and the search text. The filtered list is not a third truth. It is just albums + search, computed. So calculate it right there in the render, every time, instead of storing it in its own state.

The rule, forever: if you can calculate it from state you already have, calculate it; do not store it. Stored copies drift out of sync, which is the classic beginner bug. And because render re-runs on every keystroke (that is Stage 5’s loop), the filtered list is always fresh for free. The “items left” count later works exactly the same way.

How it runs in this exercise: call → render → paint

keystroke → onChange → setSearch(...) → React re-runs App() → during that render, albums.filter(...) recomputes filteredAlbums → filteredAlbums.map(...) builds the cards → React diffs the grid, adds/removes <article> DOM nodes → the browser paints (the filter is a function call during render; nothing "watches" it)
Senior interview lens

The senior default is derive during render. Escalate to useMemo only when the computation is measurably expensive, or when a stable reference is needed by a downstream memo or dependency array. Storing derived data in state and syncing it with an effect is the anti-pattern you are expected to name and reject on sight.

Target design — how the app should look after this stage

Typing now filters the grid by title or artist, case-insensitively, with a live count.

localhost:5173

Music Explorer

1 album(s)

cover
Purple Rain
Prince
1984
Build target

Filter by album title or artist, case-insensitively.

Interview prep

Answer each out loud first, then open to check.

Q Why not store the filtered list in state?

“Because it is fully derivable from the source list and search string. Storing it would duplicate state and create synchronization bugs.”

Q Why is this not a useEffect?

“There is no external system to synchronize with. It is a pure calculation needed for rendering.”

Q Would you use useMemo here?

“Not by default. For a modest collection, filtering is cheap. I would measure before memoizing.”

Bugs to watch for
Bug Calling .toLowerCase() on an optional/undefined API field later without normalizing it.

Calling .toLowerCase() on an optional/undefined API field later without normalizing it.

Bug Filtering the original data destructively instead of deriving a new array.

Filtering the original data destructively instead of deriving a new array.

Bug Creating filteredAlbums state plus an effect that updates it, which can get out of sync.

Creating filteredAlbums state plus an effect that updates it, which can get out of sync.

Scope guard — what NOT to add yet

Do not add useEffect or useMemo. This stage teaches pure derivation during render.

Scaffolding dilution

Instructor gives only the equation albums + search -> ?. Student writes the filter.

Checkpoint solution

An answer key after your own attempt — never a thing to copy first.

Solution Full checkpoint code

src/App.tsx

import { useState } from 'react'; import './App.css'; import AlbumCard from './components/AlbumCard'; const albums = [ { id: 1, title: 'Purple Rain', artist: 'Prince', year: 1984 }, { id: 2, title: 'Thriller', artist: 'Michael Jackson', year: 1982 }, { id: 3, title: 'Rumours', artist: 'Fleetwood Mac', year: 1977 }, ]; export default function App() { const [search, setSearch] = useState(''); const query = search.trim().toLowerCase(); const filteredAlbums = albums.filter((album) => { return ( album.title.toLowerCase().includes(query) || album.artist.toLowerCase().includes(query) ); }); return ( <main className="page"> <h1>Music Explorer</h1> <label> Search <input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Album or artist" /> </label> <p>{filteredAlbums.length} album(s)</p> <section className="album-grid"> {filteredAlbums.map((album) => ( <AlbumCard key={album.id} title={album.title} artist={album.artist} year={album.year} /> ))} </section> </main> ); }
Exit test

Ask the student to explain why filteredAlbums changes even though there is no setFilteredAlbums.

Reflection

Answer each in your own words first. If you can, the idea is yours.

Reflect Give the one-question test for “should this be state?” and apply it to filteredAlbums.

Ask: can I compute it from state I already have? filteredAlbums = albums + search, so yes — derive it during render, do not store it.

Reflect What bug does storing filteredAlbums in its own state invite?

Two sources of truth that drift apart: update albums or search and the stored copy is stale unless you remember to re-sync it. Deriving removes the sync problem entirely.

Stage 7 · Types & Network
STAGE 7

Define the App’s Own Album Type Before Trusting API Data

The Discogs response is not shaped exactly like the UI. It contains nested API-specific structures such as basic_information, artist arrays, label arrays, images, pagination metadata, and collection-instance fields.
Why this stage, and what to picture

The Discogs response is not shaped exactly like the UI. It contains nested API-specific structures such as basic_information, artist arrays, label arrays, images, pagination metadata, and collection-instance fields.

If every component understands Discogs’ raw response, the API leaks through the whole app.

Instead, define the shape the application wants.

Discogs shape App shape ------------- --------- basic_information.title -> title artists[0].name -> artist labels[0].name -> label year -> year cover_image -> coverUrl

This creates a boundary:

external system -> adapter -> app model -> components

The adapter absorbs API weirdness so components can stay simple.

Deeper intuition — builds on the last stage

Real data is about to arrive from Discogs, and its shape is messy: nested objects, arrays, optional fields. Before you touch it, decide the shape your app wants — a clean Album type with exactly the fields your card uses.

This draws a boundary. One place (an adapter, next stage) will translate Discogs’ shape into your Album; everything else depends only on your Album, never on Discogs directly. So when the API changes, one file changes. TypeScript just writes that contract down and checks it. This is architecture, not decoration.

How it runs in this exercise: call → render → paint

mount → App() runs → <AlbumCard album={album} /> → AlbumCard reads album.coverUrl / album.label, returns JSX → React builds DOM → the browser paints (TypeScript is checked at build time and is gone at runtime; the render flow is unchanged)
Senior interview lens

This is an anti-corruption layer. Separate the wire type (raw Discogs JSON) from the domain type (your Album), normalize at the boundary, and add runtime validation (e.g. Zod) at trust boundaries where bad data is plausible. It is architecture, not typing. The probe: “the API renames a field — how many files change?” The answer should be “one.”

Target design — how the app should look after this stage

Cards now match your own Album type: a cover image (or a placeholder) and the label.

localhost:5173

Music Explorer

cover
Purple Rain
Prince
1984 · Warner Bros.
cover
Thriller
Michael Jackson
1982 · Epic
No cover
Rumours
Fleetwood Mac
1977 · Warner Bros.
Build target

Create an Album type and update AlbumCard to receive one album object instead of several individual props.

Interview prep

Answer each out loud first, then open to check.

Q Why not type the raw Discogs response and use it everywhere?

“I may type the raw response at the API layer, but components should depend on the domain data they need, not on a third-party response shape. An adapter lowers coupling and gives me one place to normalize optional fields.”

Q What does TypeScript protect you from at runtime?

“Nothing by itself. TypeScript checks compile-time assumptions, but external JSON can still violate them. For higher-trust boundaries I would validate runtime data too, for example with a schema library, but I would not add that until needed for this exercise.”

Bugs to watch for
Bug Treating TypeScript as runtime validation of API JSON.

Treating TypeScript as runtime validation of API JSON.

Bug Assuming every release has a first artist, first label, or image.

Assuming every release has a first artist, first label, or image.

Bug Copying a giant third-party type into UI components and making them depend on fields they do not use.

Copying a giant third-party type into UI components and making them depend on fields they do not use.

Scope guard — what NOT to add yet

Do not add Zod or another validation library yet. First understand the adapter boundary.

Scaffolding dilution

Instructor asks which fields the UI actually needs. Student designs the Album type.

Checkpoint solution

An answer key after your own attempt — never a thing to copy first.

Solution Full checkpoint code

src/types.ts

export type Album = { id: number; title: string; artist: string; year: number | null; label: string; coverUrl: string | null; };

src/components/AlbumCard.tsx

import type { Album } from '../types'; type AlbumCardProps = { album: Album; }; export default function AlbumCard({ album }: AlbumCardProps) { return ( <article className="album-card"> {album.coverUrl ? ( <img src={album.coverUrl} alt={`${album.title} cover`} /> ) : ( <div className="cover-placeholder">No cover</div> )} <h2>{album.title}</h2> <p>{album.artist}</p> <p>{album.year ?? 'Year unknown'}</p> <p>{album.label}</p> </article> ); }

src/App.tsx

import { useState } from 'react'; import './App.css'; import AlbumCard from './components/AlbumCard'; import type { Album } from './types'; const albums: Album[] = [ { id: 1, title: 'Purple Rain', artist: 'Prince', year: 1984, label: 'Warner Bros.', coverUrl: null, }, { id: 2, title: 'Thriller', artist: 'Michael Jackson', year: 1982, label: 'Epic', coverUrl: null, }, { id: 3, title: 'Rumours', artist: 'Fleetwood Mac', year: 1977, label: 'Warner Bros.', coverUrl: null, }, ]; export default function App() { const [search, setSearch] = useState(''); const query = search.trim().toLowerCase(); const filteredAlbums = albums.filter((album) => `${album.title} ${album.artist}`.toLowerCase().includes(query), ); return ( <main className="page"> <h1>Music Explorer</h1> <input aria-label="Search albums or artists" value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Album or artist" /> <section className="album-grid"> {filteredAlbums.map((album) => ( <AlbumCard key={album.id} album={album} /> ))} </section> </main> ); }
Exit test

Show the student a nested Discogs response and ask: “Which layer should know basic_information exists?”

Expected answer: the API/adapter layer, not AlbumCard.

Reflection

Answer each in your own words first. If you can, the idea is yours.

Reflect Discogs renames a field. How many of your files should change, and why?

One — the adapter. Everything else depends on your Album domain type, not on Discogs’ shape. That is the whole point of the boundary.

Reflect Does adding TypeScript mean bad API data can’t reach your components?

No. TypeScript is compile-time only and does not validate runtime JSON. For untrusted data, add runtime validation (e.g. Zod) at the boundary; the type just documents and checks the contract.

Stage 8 · Types & Network
STAGE 8

Write a Discogs API Function Before Putting Networking in React

The application now needs real data. Beginners often put a long fetch() call directly inside a component and mix four concerns:
Why this stage, and what to picture

The application now needs real data. Beginners often put a long fetch() call directly inside a component and mix four concerns:

  • constructing the URL,
  • making HTTP requests,
  • understanding Discogs JSON,
  • rendering React UI.

We want to separate data access from rendering first.

HTTP is a conversation:

client asks server answers GET /users/.../releases --------> status + JSON

fetch returns a Promise because the answer arrives later.

The API function should be understandable without React:

username -> Promise<Album[]>
Deeper intuition — builds on the last stage

Now fetch real data, but keep it out of React for one stage so you can see it plainly. fetch asks the network for data and immediately hands back a Promise: a placeholder for a value that will arrive later, because the network takes time.

await waits for that value without freezing the page (the browser keeps working). One gotcha to bank: fetch only errors on a network failure; a 404 or 500 still “succeeds” as a response with a sad status, so you check response.ok yourself. This function has zero React in it, which proves fetching is just JavaScript, and the adapter from Stage 7 lives right here.

How it runs in this exercise: call → render → paint

getCollectionPage(username) → fetch(url) returns a Promise immediately → await the response, check response.ok → await response.json() → map the JSON into Album[] → return (no render and no paint here — this is pure data access, not yet wired into any component)
Senior interview lens

Under this sits the event loop. await does not block the UI because the browser keeps painting and handling events while the microtask waits. A senior distinguishes a rejected Promise (network failure) from a bad HTTP status, reaches for AbortController to cancel, and keeps data access framework-free for testability and portability.

Target design — how the app should look after this stage

No visible change. This stage is the Discogs API module, so the app still shows fake data.

localhost:5173

Music Explorer

cover
Purple Rain
Prince
1984
cover
Thriller
Michael Jackson
1982
cover
Rumours
Fleetwood Mac
1977

Behind the scenes only: getCollectionPage(username) → Promise<Album[]>, with zero React imports.

Build target

Create a Discogs API module that gets the public All collection folder (folder_id = 0) and maps the response into Album[].

The Discogs collection endpoint is:

GET /users/{username}/collection/folders/{folder_id}/releases

For a public collection, folder 0 represents the overall collection and is the useful read path for this challenge. The endpoint is paginated.

Interview prep

Answer each out loud first, then open to check.

Q Why check response.ok?

fetch only rejects automatically for network-level failures. HTTP 404 or 500 still resolve to a Response, so I explicitly convert non-success HTTP statuses into errors.”

Q Why return normalized data from the API module?

“It keeps third-party field names and optional structures at the boundary, making components stable if our source changes.”

Q Why not use Axios?

“Native fetch is sufficient for this read-only exercise. I would add another HTTP library only if it solves a concrete need.”

Bugs to watch for
Bug Forgetting await response.json() and trying to use the Response object like parsed data.

Forgetting await response.json() and trying to use the Response object like parsed data.

Bug Assuming fetch throws for HTTP 404/500.

Assuming fetch throws for HTTP 404/500.

Bug Exposing a private API token in browser source or a VITE_* environment variable

Vite-exposed variables become client bundle data.

Scope guard — what NOT to add yet

Do not call this function from useEffect yet. Prove the API module can be read and reasoned about independently.

Scaffolding dilution

Instructor provides the endpoint only. Student writes the fetch, status check, and mapping.

Checkpoint solution

An answer key after your own attempt — never a thing to copy first.

Solution Full checkpoint code

src/api/discogs.ts

import type { Album } from '../types'; const API_BASE = 'https://api.discogs.com'; type DiscogsCollectionResponse = { pagination: { page: number; pages: number; per_page: number; items: number; }; releases: Array<{ basic_information: { id: number; title: string; year: number; cover_image?: string; artists?: Array<{ name: string }>; labels?: Array<{ name: string }>; }; }>; }; export async function getCollectionPage( username: string, page = 1, perPage = 50, ): Promise<{ albums: Album[]; page: number; pages: number }> { const url = new URL( `${API_BASE}/users/${encodeURIComponent(username)}/collection/folders/0/releases`, ); url.searchParams.set('page', String(page)); url.searchParams.set('per_page', String(perPage)); const response = await fetch(url); if (!response.ok) { throw new Error(`Discogs request failed: ${response.status}`); } const data: DiscogsCollectionResponse = await response.json(); const albums = data.releases.map(({ basic_information: info }) => ({ id: info.id, title: info.title, artist: info.artists?.[0]?.name ?? 'Unknown artist', year: info.year || null, label: info.labels?.[0]?.name ?? 'Unknown label', coverUrl: info.cover_image ?? null, })); return { albums, page: data.pagination.page, pages: data.pagination.pages, }; }

Temporary manual proof in src/App.tsx

Do not call the function during render. For this stage, keep the prior fake UI and simply inspect the function separately, or test it temporarily from the browser console/module during development.

The architectural checkpoint is that discogs.ts has zero React imports.

Exit test

Student can explain why the API function returns a Promise and why components should not know about basic_information.

Reflection

Answer each in your own words first. If you can, the idea is yours.

Reflect fetch resolves but the app shows no data. Name two things you would check.

(1) response.ok / the status — a 404 or 500 still resolves, so check it yourself. (2) That you awaited response.json() and mapped the shape correctly. fetch only rejects on network failure.

Reflect Why keep getCollectionPage free of any React imports?

Data access is plain JavaScript you can test and reason about on its own, and it keeps the third-party boundary in one place, decoupled from rendering.

Stage 9 · Types & Network
STAGE 9

Use an Effect to Synchronize React with Discogs

We now have two separate worlds:
Why this stage, and what to picture

We now have two separate worlds:

React render world external network world ------------------ ---------------------- calculates JSX Discogs request from current values resolves later

The question is:

When the collection page appears, how do we synchronize it with the remote collection?

This is the problem useEffect is meant to solve.

React’s documentation emphasizes that Effects synchronize a component with systems outside React. They run after a render/commit, and development Strict Mode can expose unsafe effects by remounting components.

initial render albums = [] | v React commits UI | v Effect starts request | ... time passes ... | v Discogs responds | v setAlbums(...) | v new render albums = real data

Key distinction: rendering should calculate UI. Networking is a side effect.

Deeper intuition — builds on the last stage

Two worlds must now meet. React’s render must stay pure: given the current state, compute the UI and do nothing else, so React can run it whenever it likes. The network is a side effect that happens over time. You cannot fetch during render, so you fetch in an effect, which runs after the screen is painted.

The flow ties the last two stages together: first render with albums empty → paint → the effect fires → fetch (Stage 8) → when data arrives, setAlbums → that state change (Stage 5’s loop) triggers a second render with real data. The dependency array says when to re-run the effect; the ignore flag cancels a response that comes back too late. You feel this by hand now so the library in Stage 14 makes sense.

How it runs in this exercise: call → render → paint

mount → App() runs with albums = [] ← render #1 → React commits the empty grid → the browser paints "nothing" → AFTER commit, React runs the effect → effect calls getCollectionPage() → fetch → Promise ... network ... → response arrives → setAlbums(result) ← from the async callback → React schedules render #2 → App() runs with real albums → React adds <article> nodes → paint (two renders; the setState inside the callback triggers render #2. dev Strict Mode runs the effect twice on purpose)
Senior interview lens

Effects synchronize with external systems; they are not the old lifecycle hooks. Ask “what am I syncing, and when must it re-sync?” — that is the dependency array — and always write cleanup. Treat Strict Mode’s double-invoke as a free correctness test. The strongest senior signal: knowing most data fetching should not be a hand-written effect at all (that is Stage 14).

Target design — how the app should look after this stage

Real data at last: the collection loads from Discogs when the app first mounts.

localhost:5173

Music Explorer

cover
Purple Rain
Prince
1984
cover
Thriller
Michael Jackson
1982
cover
Rumours
Fleetwood Mac
1977
cover
Kind of Blue
Miles Davis
1959
cover
Blue
Joni Mitchell
1971
cover
Nevermind
Nirvana
1991

Now populated by a real request on mount.

Build target

Load page 1 of user 9000RPM when the app mounts.

Interview prep

Answer each out loud first, then open to check.

Q Why can’t you call getCollectionPage() directly at the top of the component?

“Rendering should remain pure. Calling a network request during render would perform a side effect every time the component renders.”

Q Why might an effect appear to run twice in development?

“React Strict Mode intentionally remounts/effect-checks development components to expose missing cleanup and unsafe assumptions. Production behavior is different.”

Q Do all data fetches belong in useEffect?

“Not necessarily. Framework loaders and server-state libraries can own data fetching. We are using an Effect now to understand the primitive manually.”

Bugs to watch for
Bug Omitting the dependency array and triggering a request after every render.

Omitting the dependency array and triggering a request after every render.

Bug Including albums in the dependency array while the effect itself calls setAlbums, creating a fetch loop.

Including albums in the dependency array while the effect itself calls setAlbums, creating a fetch loop.

Bug Setting state after the component has become irrelevant/unmounted without an ignore/cancel strategy.

Setting state after the component has become irrelevant/unmounted without an ignore/cancel strategy.

Scope guard — what NOT to add yet

Still no TanStack Query. We intentionally want the student to experience manual effect-based fetching before replacing it.

Scaffolding dilution

Instructor draws the lifecycle. Student writes the effect and explains every render it causes.

Checkpoint solution

An answer key after your own attempt — never a thing to copy first.

Solution Full checkpoint code

src/App.tsx

import { useEffect, useState } from 'react'; import './App.css'; import { getCollectionPage } from './api/discogs'; import AlbumCard from './components/AlbumCard'; import type { Album } from './types'; export default function App() { const [albums, setAlbums] = useState<Album[]>([]); const [search, setSearch] = useState(''); useEffect(() => { let ignore = false; async function loadCollection() { const result = await getCollectionPage('9000RPM'); if (!ignore) { setAlbums(result.albums); } } loadCollection(); return () => { ignore = true; }; }, []); const query = search.trim().toLowerCase(); const filteredAlbums = albums.filter((album) => `${album.title} ${album.artist}`.toLowerCase().includes(query), ); return ( <main className="page"> <h1>Music Explorer</h1> <input aria-label="Search albums or artists" value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Album or artist" /> <section className="album-grid"> {filteredAlbums.map((album) => ( <AlbumCard key={album.id} album={album} /> ))} </section> </main> ); }
Exit test

Ask the student to name exactly how many renders happen in the normal happy-path initial load and what values exist in each render.

A useful answer is “at least the initial render and then a render after state is populated,” while acknowledging development Strict Mode may execute additional checks.

Reflection

Answer each in your own words first. If you can, the idea is yours.

Reflect Why can’t you call fetch in the component body during render?

Render must be pure — it should only compute UI from current state, because React may run it any number of times. Fetching is a side effect, so it belongs in an effect that runs after commit.

Reflect How many renders on a normal first load, and what is in each?

At least two: render #1 with albums empty (paints a loading/empty shell), then after the effect’s fetch resolves, setAlbums triggers render #2 with real data. Dev Strict Mode runs the effect twice on purpose.

Reflect What is the ignore / cleanup flag protecting against?

A response arriving after the component moved on (unmounted, or its inputs changed) and overwriting current state — a stale update, i.e. a race.

Stage 10 · Types & Network
STAGE 10

Model Loading, Error, Success, and Empty as Different UI States

Network data is not binary “there” or “not there.” Real UIs have a small state machine:
Why this stage, and what to picture

Network data is not binary “there” or “not there.” Real UIs have a small state machine:

idle/loading -> success | -> error

And success itself might contain zero results.

If the app only renders albums, the user cannot distinguish:

  • still loading,
  • request failed,
  • collection is empty,
  • search matched nothing.

Think in states, not scattered booleans.

For this learning stage we will use simple state variables, but mentally model them as:

LOADING SUCCESS(data) ERROR(message)
Deeper intuition — builds on the last stage

A request is not simply “data” or “no data.” Over time it is loading, then either success or error, and success can be empty. If the UI only checks “do we have albums,” the user cannot tell “still loading” from “nothing here” from “it broke.”

So model the outcomes as explicit states and show a screen for each. This is still “the UI is a function of state” from Stage 5; you are just being honest that the state has several shapes. The subtle bug is a race: an old response landing after a newer one. The ignore flag from Stage 9 is exactly what prevents it.

How it runs in this exercise: call → render → paint

mount → render with loading = true → paint "Loading…" → effect runs fetch → success: setAlbums(data) + setLoading(false) ← React batches → 1 render → App() runs, loading=false branch → paint grid / empty / no-match → error: setError(msg) + setLoading(false) → App() runs, error branch → paint the message (each setState schedules a render; the current state picks the branch)
Senior interview lens

Make illegal states unrepresentable: model one status (a discriminated union) instead of loose booleans that allow nonsense combinations. A senior names the race condition precisely — two searches in flight, the slower one wins — and fixes it with an ignore flag, an AbortController, or a request id. Loading, empty, and error are a UX contract, not an afterthought.

Target design — how the app should look after this stage

Four distinct screens the user can finally tell apart.

LoadingLoading collection…
ErrorCould not load collection: 500
Empty collectionThis collection is empty.
No matchesNo albums match “zzz”.
Build target

Show a loading message, an error message, a true empty collection message, and a no-search-results message.

Interview prep

Answer each out loud first, then open to check.

Q Why is an empty result not an error?

“The request succeeded and the domain result contains no matching items. Error and empty require different user actions and messaging.”

Q What is a race condition in fetching?

“If multiple requests are in flight, an older response can arrive after a newer one and overwrite current state unless requests are canceled, ignored, or managed by a data layer.”

Bugs to watch for
Bug Never resetting loading in an error path.

Never resetting loading in an error path.

Bug Displaying “No albums” for one frame before the first request finishes.

Displaying “No albums” for one frame before the first request finishes.

Bug Swallowing the actual error and leaving the page blank.

Swallowing the actual error and leaving the page blank.

Scope guard — what NOT to add yet

Do not create fancy skeleton components yet. Correct state semantics are more important than polish.

Scaffolding dilution

Student should design the four user-visible cases before writing conditionals.

Checkpoint solution

An answer key after your own attempt — never a thing to copy first.

Solution Full checkpoint code

src/App.tsx

import { useEffect, useState } from 'react'; import './App.css'; import { getCollectionPage } from './api/discogs'; import AlbumCard from './components/AlbumCard'; import type { Album } from './types'; export default function App() { const [albums, setAlbums] = useState<Album[]>([]); const [search, setSearch] = useState(''); const [loading, setLoading] = useState(true); const [error, setError] = useState<string | null>(null); useEffect(() => { let ignore = false; async function loadCollection() { try { setLoading(true); setError(null); const result = await getCollectionPage('9000RPM'); if (!ignore) { setAlbums(result.albums); } } catch (caught) { if (!ignore) { setError(caught instanceof Error ? caught.message : 'Unknown error'); } } finally { if (!ignore) { setLoading(false); } } } loadCollection(); return () => { ignore = true; }; }, []); const query = search.trim().toLowerCase(); const filteredAlbums = albums.filter((album) => `${album.title} ${album.artist}`.toLowerCase().includes(query), ); if (loading) { return <main className="page"><p>Loading collection…</p></main>; } if (error) { return <main className="page"><p role="alert">Could not load collection: {error}</p></main>; } return ( <main className="page"> <h1>Music Explorer</h1> <input aria-label="Search albums or artists" value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Album or artist" /> {albums.length === 0 ? ( <p>This collection is empty.</p> ) : filteredAlbums.length === 0 ? ( <p>No albums match “{search}”.</p> ) : ( <section className="album-grid"> {filteredAlbums.map((album) => ( <AlbumCard key={album.id} album={album} /> ))} </section> )} </main> ); }
Exit test

Give four scenarios and ask which branch renders:

  1. request still running,
  2. HTTP 500,
  3. successful collection with zero releases,
  4. collection has releases but search has zero matches.
Reflection

Answer each in your own words first. If you can, the idea is yours.

Reflect Why is an empty result not the same as an error?

The request succeeded; there simply are no matching items. Empty and error call for different messages and different user actions, so they are different states.

Reflect Two searches are in flight and the slower one returns last. What happens, and how do you fix it?

The older, stale response overwrites the newer result. Fix it with an ignore flag, an AbortController, or a request id so only the latest response is applied.

Reflect Why prefer one status value over separate loading/error booleans?

Booleans allow impossible combinations (loading AND error). A single status (loading | success | error) makes illegal states unrepresentable.

Stage 11 · Types & Network
STAGE 11

Fetch Album Details Only After the User Asks for Them

The challenge requires track list, artist, label, release date, and other album details.
Why this stage, and what to picture

The challenge requires track list, artist, label, release date, and other album details.

A tempting implementation is:

load 500 collection items | v make 500 detail requests

That is wasteful, slow, and risky under API limits.

The first-principles optimization is much simpler:

Do not fetch information the user has not asked to see.

collection request | v lightweight cards | user clicks ID 123 | v GET /releases/123 | v track list + details

This is lazy detail loading.

Deeper intuition — builds on the last stage

You could fetch every album’s details up front, but that is hundreds of requests for data nobody asked to see. Instead fetch on intent: when the user clicks an album, then load its details. The cheapest request is the one you never make.

Mechanically this is Stage 9 again, an effect that fetches, but the trigger is a click that sets selectedId rather than the component mounting. The one trap: when the selection changes, reset the detail state so a stale panel from the previous album does not linger. Putting the id in the effect’s dependency array is what re-syncs it.

How it runs in this exercise: call → render → paint

click a card → the browser fires a click event → onClick → setSelectedId(id) → App re-renders, sees selectedId !== null → renders <AlbumDetail id={id} /> → AlbumDetail mounts → its effect runs getReleaseDetails(id) → fetch → Promise ... network ... → setDetails(result) → AlbumDetail re-renders with data → React builds the tracklist DOM → the browser paints (the click triggers a render; the effect triggers a second one)
Senior interview lens

This is a data-fetching strategy question: waterfalls vs parallel requests, fetch-on-render vs render-as-you-fetch, and prefetch-on-intent (hover or focus). A senior weighs latency against request volume and rate limits, and treats the stale-panel bug as what it really is — a cancellation and identity problem, not a rendering one.

Target design — how the app should look after this stage

Click an album and its full detail, release date and track list, loads on demand.

localhost:5173
← Back to collection
cover
Purple Rain
Prince
Warner Bros. · 1984-06-25
Tracks
  1. A1 · Let’s Go Crazy 4:39
  2. A2 · Take Me With U 3:54
  3. A3 · The Beautiful Ones 5:13
  4. A4 · Computer Blue 3:59
  5. B1 · When Doves Cry 5:54
Build target

Make an album selectable. When selected, fetch /releases/{id} and show a detail panel with track list.

Interview prep

Answer each out loud first, then open to check.

Q Why not request every detail up front?

“Most users will inspect only a subset. Request-on-demand lowers startup latency, request volume, and rate-limit pressure.”

Q What tradeoff does lazy loading introduce?

“The first detail view has a network wait. We can later cache or prefetch likely next data if measurements justify it.”

Bugs to watch for
Bug Using the collection instance_id when the release-detail endpoint expects the release ID.

Using the collection instance_id when the release-detail endpoint expects the release ID.

Bug Assuming tracklist is always present and non-empty.

Assuming tracklist is always present and non-empty.

Bug Leaving an older selected album’s details visible while a newer selection is loading, creating a misleading UI.

Leaving an older selected album’s details visible while a newer selection is loading, creating a misleading UI.

Scope guard — what NOT to add yet

Do not add routing yet. First prove that selection state and on-demand fetching make sense.

Scaffolding dilution

Instructor provides only the detail endpoint. Student designs selectedId and the loading sequence.

Checkpoint solution

An answer key after your own attempt — never a thing to copy first.

Solution Full checkpoint code

Add to src/types.ts

export type Track = { position: string; title: string; duration: string; }; export type AlbumDetails = Album & { released: string; tracks: Track[]; };

Add to src/api/discogs.ts

import type { Album, AlbumDetails } from '../types'; // Keep the existing collection code above. type DiscogsReleaseResponse = { id: number; title: string; year: number; released?: string; images?: Array<{ uri?: string; resource_url?: string }>; artists?: Array<{ name: string }>; labels?: Array<{ name: string }>; tracklist?: Array<{ position?: string; title: string; duration?: string; }>; }; export async function getReleaseDetails(id: number): Promise<AlbumDetails> { const response = await fetch(`https://api.discogs.com/releases/${id}`); if (!response.ok) { throw new Error(`Release request failed: ${response.status}`); } const data: DiscogsReleaseResponse = await response.json(); return { id: data.id, title: data.title, artist: data.artists?.[0]?.name ?? 'Unknown artist', year: data.year || null, label: data.labels?.[0]?.name ?? 'Unknown label', coverUrl: data.images?.[0]?.uri ?? null, released: data.released ?? 'Unknown release date', tracks: (data.tracklist ?? []).map((track) => ({ position: track.position ?? '', title: track.title, duration: track.duration ?? '', })), }; }

Update src/components/AlbumCard.tsx

import type { Album } from '../types'; type AlbumCardProps = { album: Album; onSelect: (id: number) => void; }; export default function AlbumCard({ album, onSelect }: AlbumCardProps) { return ( <button className="album-card" onClick={() => onSelect(album.id)}> {album.coverUrl ? ( <img src={album.coverUrl} alt={`${album.title} cover`} loading="lazy" /> ) : ( <div className="cover-placeholder">No cover</div> )} <h2>{album.title}</h2> <p>{album.artist}</p> <p>{album.year ?? 'Year unknown'}</p> </button> ); }

src/components/AlbumDetail.tsx

import { useEffect, useState } from 'react'; import { getReleaseDetails } from '../api/discogs'; import type { AlbumDetails } from '../types'; type AlbumDetailProps = { id: number; onClose: () => void; }; export default function AlbumDetail({ id, onClose }: AlbumDetailProps) { const [details, setDetails] = useState<AlbumDetails | null>(null); const [error, setError] = useState<string | null>(null); useEffect(() => { let ignore = false; setDetails(null); setError(null); getReleaseDetails(id) .then((result) => { if (!ignore) setDetails(result); }) .catch((caught) => { if (!ignore) { setError(caught instanceof Error ? caught.message : 'Unknown error'); } }); return () => { ignore = true; }; }, [id]); if (error) return <p role="alert">{error}</p>; if (!details) return <p>Loading album details…</p>; return ( <section> <button onClick={onClose}>Back to collection</button> <h2>{details.title}</h2> <p>{details.artist}</p> <p>{details.label}</p> <p>{details.released}</p> <h3>Tracks</h3> <ol> {details.tracks.map((track, index) => ( <li key={`${track.position}-${track.title}-${index}`}> {track.position} {track.title} {track.duration} </li> ))} </ol> </section> ); }

Conceptual App.tsx change

Keep the Stage 10 collection-loading code and add:

const [selectedId, setSelectedId] = useState<number | null>(null); if (selectedId !== null) { return ( <main className="page"> <AlbumDetail id={selectedId} onClose={() => setSelectedId(null)} /> </main> ); }

Then render each card with:

<AlbumCard key={album.id} album={album} onSelect={setSelectedId} />
Exit test

Ask: “If the collection has 400 albums and the user opens 3, roughly how many detail requests should we make?”

Expected answer: about 3 detail requests, not 400, before considering caching/revisits.

Reflection

Answer each in your own words first. If you can, the idea is yours.

Reflect Why fetch details on click rather than for every album up front?

Most details are never viewed; fetching all of them is wasteful and risks rate limits. Fetch on intent — the cheapest request is the one you never make.

Reflect The panel briefly shows the previous album while the new one loads. Why, and what is the fix?

The effect keyed on id fetches the new details, but the old state is still rendered until they arrive. Reset the detail state when id changes (and key the effect on id) so nothing stale lingers.

Stage 12 · Routing & Data
STAGE 12

Move Selection into the URL with Routing

The selection-state version works, but it has a browser problem:
Why this stage, and what to picture

The selection-state version works, but it has a browser problem:

selectedId = 123

exists only in memory.

Refresh the page and it disappears. Copy the URL and another person cannot open that album. Browser back does not naturally represent moving between the list and detail screen.

The URL is already a state mechanism built into the web platform.

Instead of:

React memory: selectedId = 123

we can express navigation state as:

/albums/123

Now the browser can participate:

URL -> route match -> component -> release ID -> data
Deeper intuition — builds on the last stage

selectedId from Stage 11 lives only in memory, so a refresh loses it and you cannot share a link to an album. The web already has a place for “what am I looking at” — the URL. Move the selection there: /albums/123.

Now the browser participates: back, refresh, and shareable links all work for free, because the URL is the source of truth for navigation. A route param like :id is just state that lives in the address bar (and it is text, so you convert it to a number). It is the same fetch-on-id as Stage 11; only the trigger moved from a click handler to the URL.

How it runs in this exercise: call → render → paint

click <Link to="/albums/123"> → React Router intercepts the click (preventDefault) → calls history.pushState → the URL changes, NO full page reload → Router re-renders, matches /albums/:id → <AlbumPage/> → useParams() reads "123" → <AlbumDetail id={123}/> → effect fetches → setDetails → render → the browser paints (navigation is the browser History API, driven by Router — no server round trip)
Senior interview lens

The URL is shared, serializable application state. Decide deliberately what belongs there (navigational, shareable, back-button-worthy — put filters in query params when sharing matters) versus ephemeral local state. Know that SPA routing swaps views via the History API with no server round trip, and that this is exactly what pushes teams toward SSR / RSC for SEO and deep links.

Target design — how the app should look after this stage

The selected album now lives in the URL, so it survives refresh and is shareable.

localhost:5173/albums/249504
← Back to collection
cover
Purple Rain
Prince
Warner Bros. · 1984-06-25
Tracks
  1. A1 · Let’s Go Crazy 4:39
  2. A2 · Take Me With U 3:54
  3. A3 · The Beautiful Ones 5:13
  4. A4 · Computer Blue 3:59
  5. B1 · When Doves Cry 5:54
Build target

Create:

/ collection /albums/:id album details
npm install react-router-dom
Interview prep

Answer each out loud first, then open to check.

Q Why put the selected album in the URL?

“It is navigational state. Encoding it in the URL makes the view refreshable, linkable, bookmarkable, and compatible with browser history.”

Q What is a route parameter?

“A dynamic portion of the URL pattern, such as :id, that the matched route exposes to the component.”

Q What state should not necessarily go in the URL?

“Ephemeral UI details such as whether a tooltip is open. Search/filter state may belong in query parameters if sharing and back/forward behavior matter.”

Bugs to watch for
Bug Forgetting that useParams() returns strings and passing the ID as if it were already a number.

Forgetting that useParams() returns strings and passing the ID as if it were already a number.

Bug Creating a nested clickable element such as a button inside a link with conflicting interaction semantics.

Creating a nested clickable element such as a button inside a link with conflicting interaction semantics.

Bug Forgetting a route for unknown paths or invalid release IDs.

Forgetting a route for unknown paths or invalid release IDs.

Scope guard — what NOT to add yet

Do not move every piece of state into the router. The purpose is specifically to make navigation state URL-addressable.

Scaffolding dilution

Student should derive the route pattern from the product behavior before seeing router syntax.

Checkpoint solution

An answer key after your own attempt — never a thing to copy first.

Solution Full checkpoint code

src/main.tsx

import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; import './index.css'; import App from './App'; createRoot(document.getElementById('root')!).render( <StrictMode> <BrowserRouter> <App /> </BrowserRouter> </StrictMode>, );

src/App.tsx

import { Route, Routes } from 'react-router-dom'; import './App.css'; import AlbumPage from './pages/AlbumPage'; import CollectionPage from './pages/CollectionPage'; export default function App() { return ( <Routes> <Route path="/" element={<CollectionPage />} /> <Route path="/albums/:id" element={<AlbumPage />} /> <Route path="*" element={<main className="page"><h1>Not found</h1></main>} /> </Routes> ); }

src/pages/CollectionPage.tsx

Move the Stage 10 collection loading/filtering logic into this component and replace card selection with links:

import { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; import { getCollectionPage } from '../api/discogs'; import AlbumCard from '../components/AlbumCard'; import type { Album } from '../types'; export default function CollectionPage() { const [albums, setAlbums] = useState<Album[]>([]); const [search, setSearch] = useState(''); const [loading, setLoading] = useState(true); const [error, setError] = useState<string | null>(null); useEffect(() => { let ignore = false; getCollectionPage('9000RPM') .then((result) => { if (!ignore) setAlbums(result.albums); }) .catch((caught) => { if (!ignore) setError(caught instanceof Error ? caught.message : 'Unknown error'); }) .finally(() => { if (!ignore) setLoading(false); }); return () => { ignore = true; }; }, []); const query = search.trim().toLowerCase(); const filteredAlbums = albums.filter((album) => `${album.title} ${album.artist}`.toLowerCase().includes(query), ); if (loading) return <main className="page">Loading…</main>; if (error) return <main className="page" role="alert">{error}</main>; return ( <main className="page"> <h1>Music Explorer</h1> <input aria-label="Search albums or artists" value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Album or artist" /> <section className="album-grid"> {filteredAlbums.map((album) => ( <Link key={album.id} to={`/albums/${album.id}`}> <AlbumCard album={album} /> </Link> ))} </section> </main> ); }

For this stage, return AlbumCard to a display-only component that no longer needs onSelect.

src/pages/AlbumPage.tsx

import { Link, useParams } from 'react-router-dom'; import AlbumDetail from '../components/AlbumDetail'; export default function AlbumPage() { const { id } = useParams(); const releaseId = Number(id); if (!Number.isInteger(releaseId) || releaseId <= 0) { return ( <main className="page"> <p>Invalid album ID.</p> <Link to="/">Back to collection</Link> </main> ); } return ( <main className="page"> <Link to="/">← Back to collection</Link> <AlbumDetail id={releaseId} onClose={() => {}} /> </main> ); }

At this checkpoint, remove the now-redundant close button from AlbumDetail or make it optional. The important design change is that the route owns selection.

Exit test

Ask the student what should happen if they paste /albums/123 directly into a fresh tab. They should be able to trace route match -> param -> fetch -> detail UI.

Reflection

Answer each in your own words first. If you can, the idea is yours.

Reflect selectedId works. Name three things it can’t do that a URL can.

Survive a refresh, be shared or bookmarked as a link, and work with the browser’s back/forward buttons — because in-memory state is invisible to the browser, and the URL is not.

Reflect useParams gives you id === '123'. What must you do before using it, and why?

Convert it to a number — URLs are text. Passing the string where a number is expected causes subtle comparison and lookup mismatches.

Stage 13 · Routing & Data
STAGE 13

Handle Pagination Before Pretending the First Page Is the Collection

The Discogs collection endpoint is paginated. A successful request for page 1 does not mean “we loaded the collection.” It means “we loaded one page of the collection.”
Why this stage, and what to picture

The Discogs collection endpoint is paginated. A successful request for page 1 does not mean “we loaded the collection.” It means “we loaded one page of the collection.”

This is a classic frontend interview trap: the UI looks correct with small test data but silently drops data in production.

The first-principles question is:

How do we know whether more data exists?

The server tells us in pagination metadata.

page 1 response ├── releases: [...] └── pagination.pages: 5 user clicks "Load more" | v request page 2 | v old albums + new albums

This is different from detail fetching:

  • collection pages add more list items,
  • detail requests enrich one selected item.
Deeper intuition — builds on the last stage

Discogs returns the collection in pages; page 1 is not the whole thing. To show more, request the next page and add it to what you already have. And “add to a list” is the immutable move you first met with state: build a new array, do not mutate the old one.

setAlbums(current => [...current, ...next]) reads “take the latest list, return a longer one.” React keeps the existing cards (thanks to the stable keys from Stage 4) and paints only the new ones. Advance the page number only after the request succeeds, so an error never makes you skip data.

How it runs in this exercise: call → render → paint

click "Load more" → onClick → loadMore() → setLoadingMore(true) → render → button shows "Loading…" → await getCollectionPage(nextPage) → fetch → response → setAlbums(current => [...current, ...next]) + setPage + setLoadingMore(false) → App() re-runs, maps the longer array → React KEEPS existing <article> nodes (stable keys) and appends the new ones → the browser paints only the added cards
Senior interview lens

Know offset vs cursor pagination, and why offset pagination double-counts or skips when the data shifts under you. The append uses a functional update for the same stale-closure reason as before. A senior also flags dedupe and stable keys, and knows when infinite scroll (IntersectionObserver) plus list virtualization is worth the added complexity.

Target design — how the app should look after this stage

Only page 1 loads first; a Load more button appends the next page.

localhost:5173

Music Explorer

cover
Purple Rain
Prince
1984
cover
Thriller
Michael Jackson
1982
cover
Rumours
Fleetwood Mac
1977
cover
Kind of Blue
Miles Davis
1959
cover
Blue
Joni Mitchell
1971
cover
Nevermind
Nirvana
1991
Page 1 of 5
Build target

Load page 1 first, then allow the user to explicitly load the next page until there are no more pages.

Interview prep

Answer each out loud first, then open to check.

Q Infinite scroll or “Load more”?

“For a challenge I would start with Load more because it is simpler, accessible, and gives explicit control. Infinite scrolling can be added if the product requires it.”

Q Why not fetch every page immediately?

“That increases initial latency and request volume. Incremental loading is a safer default, especially under rate limits.”

Q How do you avoid duplicate list items?

“Use stable release IDs and make pagination transitions deterministic. If the API can return duplicates because the same release has multiple collection instances, decide whether the product represents releases or physical collection instances and key accordingly.”

Bugs to watch for
Bug Replacing the old list with page 2 instead of appending page 2.

Replacing the old list with page 2 instead of appending page 2.

Bug Incrementing the page before a request succeeds and skipping data after an error.

Incrementing the page before a request succeeds and skipping data after an error.

Bug Assuming release ID is always unique per physical collection entry

Discogs can represent multiple instances of the same release; the product must decide whether duplicates matter.

Scope guard — what NOT to add yet

Do not add infinite scrolling, Intersection Observer, virtualization, and query caching all at once. Pagination is the only new concept.

Scaffolding dilution

Instructor shows the pagination object. Student decides what state is needed and when the button disappears.

Checkpoint solution

An answer key after your own attempt — never a thing to copy first.

Solution Full checkpoint code

Update src/pages/CollectionPage.tsx

import { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; import { getCollectionPage } from '../api/discogs'; import AlbumCard from '../components/AlbumCard'; import type { Album } from '../types'; export default function CollectionPage() { const [albums, setAlbums] = useState<Album[]>([]); const [search, setSearch] = useState(''); const [page, setPage] = useState(1); const [pages, setPages] = useState(1); const [loading, setLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); const [error, setError] = useState<string | null>(null); useEffect(() => { let ignore = false; async function loadFirstPage() { try { setLoading(true); const result = await getCollectionPage('9000RPM', 1, 50); if (!ignore) { setAlbums(result.albums); setPage(result.page); setPages(result.pages); } } catch (caught) { if (!ignore) { setError(caught instanceof Error ? caught.message : 'Unknown error'); } } finally { if (!ignore) setLoading(false); } } loadFirstPage(); return () => { ignore = true; }; }, []); async function loadMore() { if (loadingMore || page >= pages) return; const nextPage = page + 1; try { setLoadingMore(true); const result = await getCollectionPage('9000RPM', nextPage, 50); setAlbums((current) => [...current, ...result.albums]); setPage(result.page); setPages(result.pages); } catch (caught) { setError(caught instanceof Error ? caught.message : 'Unknown error'); } finally { setLoadingMore(false); } } const query = search.trim().toLowerCase(); const filteredAlbums = albums.filter((album) => `${album.title} ${album.artist}`.toLowerCase().includes(query), ); if (loading) return <main className="page">Loading collection…</main>; if (error && albums.length === 0) { return <main className="page" role="alert">{error}</main>; } return ( <main className="page"> <h1>Music Explorer</h1> <input aria-label="Search albums or artists" value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Album or artist" /> {error && <p role="alert">Latest request failed: {error}</p>} <section className="album-grid"> {filteredAlbums.map((album) => ( <Link key={album.id} to={`/albums/${album.id}`}> <AlbumCard album={album} /> </Link> ))} </section> {page < pages && ( <button onClick={loadMore} disabled={loadingMore}> {loadingMore ? 'Loading…' : 'Load more'} </button> )} </main> ); }
Exit test

Student can explain why setAlbums(current => [...current, ...newAlbums]) uses a functional state update.

Expected answer: the next list depends on the latest previous list, so the functional form avoids relying on a possibly stale closure.

Reflection

Answer each in your own words first. If you can, the idea is yours.

Reflect Why is the append written as setAlbums(current => [...current, ...next]) rather than replacing?

The functional form reads the latest list (avoiding a stale closure), and appending adds the next page to what is already shown. Replacing would drop the earlier pages.

Reflect “Page 1 loaded, so we’ve loaded the collection.” What is the flaw?

Page 1 is one page of a paginated collection; pagination.pages says how many exist. Trusting page 1 silently drops the rest — it just looks fine on small test data.

Stage 14 · Routing & Data
STAGE 14

Replace Hand-Rolled Server-State Plumbing with TanStack Query

By now the student has manually built:
Why this stage, and what to picture

By now the student has manually built:

  • loading state,
  • error state,
  • network Effects,
  • duplicate-request concerns,
  • page fetching,
  • detail fetching,
  • stale response concerns.

Now a server-state library has a clear reason to exist.

TanStack Query is not “better useState.” It manages remote asynchronous state whose source of truth lives on a server.

Compare two categories:

CLIENT/UI STATE SERVER STATE --------------- ------------ search text Discogs collection sort selection release details modal open/closed loading/error/freshness

TanStack Query associates remote data with a stable query key:

['collection', username, page] -> cached page data ['release', id] -> cached release details

If the same query is needed again, the cache can participate instead of blindly starting from zero.

Deeper intuition — builds on the last stage

Step back and look at everything you hand-built across Stages 9, 10, and 13: loading and error states, effects, guarding against stale responses, caching pages, refetching. That whole cluster is one thing — server state — and it is a different animal from the local state (search, page) this screen owns.

Server state is owned by a server: it can go stale, be shared across screens, and be cached. A library like TanStack Query manages exactly that, keyed by a query key (the identity of the data). You can only appreciate the win because you did it the hard way first: this stage is “replace my plumbing,” not “a fancier useState.”

How it runs in this exercise: call → render → paint

mount → useQuery(['collection', user, page]) runs → cache miss → the library calls your queryFn (getCollectionPage) → status 'pending' → App renders "Loading…" → paint → Promise resolves → the library stores data under the key, status 'success' → the library triggers a re-render of the subscribed component → App renders the grid → paint (change page → new key → new query, or a cache HIT → instant render, no fetch. the LIBRARY now owns the setState that triggers your re-renders)
Senior interview lens

This is the distinction that most signals real experience: server state is not client state. It needs caching, request deduping, background refetch, staleTime vs garbage-collection time, and invalidation — one of the genuinely hard problems. Query keys are cache identity. Reasoning about optimistic updates and cache invalidation out loud is what “I have shipped real apps” sounds like.

Target design — how the app should look after this stage

Same UI, but the collection and details are now cached server state (TanStack Query).

localhost:5173

Music Explorer

cover
Purple Rain
Prince
1984
cover
Thriller
Michael Jackson
1982
cover
Rumours
Fleetwood Mac
1977
cover
Kind of Blue
Miles Davis
1959
cover
Blue
Joni Mitchell
1971
cover
Nevermind
Nirvana
1991
Build target

Move collection and release-detail server state into TanStack Query while keeping search as ordinary React state.

npm install @tanstack/react-query
Interview prep

Answer each out loud first, then open to check.

Q Why does search stay in useState?

“Search is local UI state owned by this interface. Discogs data is remote server state with loading, caching, staleness, and synchronization concerns.”

Q What is a query key?

“A serializable identity for the remote data. It lets the library cache, share, refetch, and invalidate the correct data.”

Q What does staleTime mean?

“It is how long fetched data is considered fresh. While fresh, TanStack Query can avoid freshness-driven refetches for that query. It is not the same as how long cached data stays in memory.”

Q Why did the app refetch when I focused the window?

“TanStack Query’s defaults can refetch stale queries on events such as window focus or reconnect. Those defaults are useful, but should be understood rather than treated as magic.”

Bugs to watch for
Bug Using the same query key for different release IDs, causing cache collisions.

Using the same query key for different release IDs, causing cache collisions.

Bug Forgetting to include a changing dependency such as page or id in the query key.

Forgetting to include a changing dependency such as page or id in the query key.

Bug Assuming cache means “never request again,” ignoring staleness/refetch policies.

Assuming cache means “never request again,” ignoring staleness/refetch policies.

Scope guard — what NOT to add yet

Do not add Redux or Zustand to “manage the Query data.” That would reintroduce duplicate ownership.

Scaffolding dilution

Instructor asks the student to classify each variable as UI state or server state. Student performs the migration.

Checkpoint solution

An answer key after your own attempt — never a thing to copy first.

Solution Full checkpoint code

src/main.tsx

import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import './index.css'; import App from './App'; const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 5 * 60 * 1000, retry: 1, }, }, }); createRoot(document.getElementById('root')!).render( <StrictMode> <QueryClientProvider client={queryClient}> <BrowserRouter> <App /> </BrowserRouter> </QueryClientProvider> </StrictMode>, );

src/pages/CollectionPage.tsx

import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { Link } from 'react-router-dom'; import { getCollectionPage } from '../api/discogs'; import AlbumCard from '../components/AlbumCard'; export default function CollectionPage() { const [search, setSearch] = useState(''); const [page, setPage] = useState(1); const collectionQuery = useQuery({ queryKey: ['collection', '9000RPM', page], queryFn: () => getCollectionPage('9000RPM', page, 50), }); if (collectionQuery.isPending) { return <main className="page">Loading collection…</main>; } if (collectionQuery.isError) { return ( <main className="page" role="alert"> {collectionQuery.error.message} </main> ); } const { albums, pages } = collectionQuery.data; const query = search.trim().toLowerCase(); const filteredAlbums = albums.filter((album) => `${album.title} ${album.artist}`.toLowerCase().includes(query), ); return ( <main className="page"> <h1>Music Explorer</h1> <input aria-label="Search albums or artists" value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Album or artist" /> <section className="album-grid"> {filteredAlbums.map((album) => ( <Link key={album.id} to={`/albums/${album.id}`}> <AlbumCard album={album} /> </Link> ))} </section> <nav aria-label="Collection pages"> <button onClick={() => setPage((p) => p - 1)} disabled={page === 1}> Previous </button> <span> Page {page} of {pages} </span> <button onClick={() => setPage((p) => p + 1)} disabled={page === pages}> Next </button> </nav> </main> ); }

src/components/AlbumDetail.tsx

import { useQuery } from '@tanstack/react-query'; import { getReleaseDetails } from '../api/discogs'; type AlbumDetailProps = { id: number; }; export default function AlbumDetail({ id }: AlbumDetailProps) { const releaseQuery = useQuery({ queryKey: ['release', id], queryFn: () => getReleaseDetails(id), staleTime: 60 * 60 * 1000, }); if (releaseQuery.isPending) return <p>Loading album details…</p>; if (releaseQuery.isError) return <p role="alert">{releaseQuery.error.message}</p>; const details = releaseQuery.data; return ( <section> <h1>{details.title}</h1> <p>{details.artist}</p> <p>{details.label}</p> <p>{details.released}</p> <h2>Tracks</h2> <ol> {details.tracks.map((track, index) => ( <li key={`${track.position}-${track.title}-${index}`}> {track.position} {track.title} {track.duration} </li> ))} </ol> </section> ); }

src/pages/AlbumPage.tsx

import { Link, useParams } from 'react-router-dom'; import AlbumDetail from '../components/AlbumDetail'; export default function AlbumPage() { const { id } = useParams(); const releaseId = Number(id); if (!Number.isInteger(releaseId) || releaseId <= 0) { return <main className="page">Invalid release ID.</main>; } return ( <main className="page"> <Link to="/">← Back to collection</Link> <AlbumDetail id={releaseId} /> </main> ); }
Exit test

Ask the student:

“I visit release 123, go back, then open release 123 again. What determines whether a new network request occurs?”

A strong answer mentions the query key, whether data is in cache, whether it is stale, and refetch policy.

Reflection

Answer each in your own words first. If you can, the idea is yours.

Reflect Why keep search in useState but move album data to TanStack Query?

search is local UI state this screen owns; album data is server state — remote, shareable, cacheable, refetchable. Different lifecycles, different tools.

Reflect What is a query key, and what happens when the page in it changes?

It is the cache identity of the data. A new page makes a new key: a cache miss fetches, a cache hit renders instantly with no fetch.

Reflect Which earlier-stage plumbing does the library replace?

The hand-built loading/error state, the fetching effect, stale-response guarding, and page caching from Stages 9, 10, and 13.

Stage 15 · Production
STAGE 15

Introduce Tailwind Only After CSS Is Understood

The challenge asks for consistent styling. Plain CSS has already taught the underlying layout model. Now Tailwind can speed implementation without becoming mysterious syntax.
Why this stage, and what to picture

The challenge asks for consistent styling. Plain CSS has already taught the underlying layout model. Now Tailwind can speed implementation without becoming mysterious syntax.

The motivation is not:

“Tailwind is modern.”

It is:

“We understand CSS. Now we want a consistent utility vocabulary that makes repeated spacing, typography, responsive layout, and states fast to apply.”

Tailwind v4’s Vite integration uses the dedicated @tailwindcss/vite plugin and a CSS @import "tailwindcss".

Tailwind utilities are still CSS decisions:

flex -> display: flex p-4 -> padding token rounded-xl -> border radius token grid -> display: grid md:... -> media-query condition

The student should be able to translate a utility back into its CSS purpose.

Deeper intuition — builds on the last stage

The app works; now make it look consistent. Tailwind’s classes are just CSS with short names: flex is display:flex, p-4 is padding. Nothing new happens in the browser, and you already know the CSS underneath, which is exactly why the earlier stages used plain CSS first.

The value is a shared vocabulary for spacing, type, and responsive layout, so the whole app feels like one thing. Mobile-first is the mental model: the plain class applies at every width, and md: / lg: add rules as the screen grows. You should be able to translate any class back to the CSS rule it stands for.

How it runs in this exercise: call → render → paint

(the JavaScript loop is identical to before) render → App returns JSX whose elements carry Tailwind classes → React builds the DOM with those class names → the browser matches the classes to CSS rules and paints the styles (nothing new at run time; Tailwind only changes which CSS the browser applies)
Senior interview lens

Talk about the tradeoffs, not the trend: utility-first speed and consistency vs class-list noise, the cascade and specificity, and when to extract a component instead of repeating utilities. A senior mentions design tokens, mobile-first responsiveness, and that layout and paint cost is a real performance concern (avoid layout thrash). Styling is a system decision.

Target design — how the app should look after this stage

The same app, now with a consistent, responsive design system.

localhost:5173
Discogs collection

Music Explorer

cover
Purple Rain
Prince
1984
cover
Thriller
Michael Jackson
1982
cover
Rumours
Fleetwood Mac
1977
cover
Kind of Blue
Miles Davis
1959
cover
Blue
Joni Mitchell
1971
cover
Nevermind
Nirvana
1991
Build target

Create a responsive album grid, consistent card spacing, clear hover/focus states, and a readable detail page.

npm install tailwindcss @tailwindcss/vite

vite.config.ts

import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import tailwindcss from '@tailwindcss/vite'; export default defineConfig({ plugins: [react(), tailwindcss()], });

src/index.css

@import "tailwindcss";
Interview prep

Answer each out loud first, then open to check.

Q What are the risks of utility-first CSS?

“Class lists can become noisy and repeated patterns can drift. I would extract semantic components when a pattern is truly repeated, while keeping one-off layout utilities local.”

Q CSS Grid or Flexbox for the album collection?

“Grid is a natural fit for a two-dimensional responsive gallery. Flexbox is better suited to one-dimensional alignment such as the detail header or toolbar.”

Q How do you make the grid responsive?

“Either explicit breakpoints or an auto-fit/minmax grid strategy. With Tailwind I can use responsive column utilities while preserving a mobile-first base.”

Bugs to watch for
Bug Styling only mouse hover and forgetting keyboard focus visibility.

Styling only mouse hover and forgetting keyboard focus visibility.

Bug Giving images fixed dimensions that distort cover art instead of using aspect ratio/object-fit.

Giving images fixed dimensions that distort cover art instead of using aspect ratio/object-fit.

Bug Copying enormous utility strings everywhere instead of extracting a repeated component.

Copying enormous utility strings everywhere instead of extracting a repeated component.

Scope guard — what NOT to add yet

Do not add a second component library such as MUI just for prettier buttons. One styling system is enough.

Scaffolding dilution

Instructor gives a screenshot/wireframe, not class names. Student chooses layout utilities and explains the corresponding CSS.

Checkpoint solution

An answer key after your own attempt — never a thing to copy first.

Solution Full checkpoint code

src/components/AlbumCard.tsx

import type { Album } from '../types'; type AlbumCardProps = { album: Album; }; export default function AlbumCard({ album }: AlbumCardProps) { return ( <article className="group overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm transition hover:-translate-y-0.5 hover:shadow-md focus-within:ring-2 focus-within:ring-zinc-900"> {album.coverUrl ? ( <img src={album.coverUrl} alt={`${album.title} cover`} loading="lazy" className="aspect-square w-full object-cover" /> ) : ( <div className="grid aspect-square place-items-center bg-zinc-100 text-sm text-zinc-500"> No cover </div> )} <div className="space-y-1 p-4"> <h2 className="line-clamp-2 font-semibold text-zinc-950">{album.title}</h2> <p className="truncate text-sm text-zinc-600">{album.artist}</p> <p className="text-xs text-zinc-500">{album.year ?? 'Year unknown'}</p> </div> </article> ); }

Key layout from src/pages/CollectionPage.tsx

Replace presentation classes with:

<main className="mx-auto min-h-screen max-w-7xl px-4 py-8 sm:px-6 lg:px-8"> <header className="mb-8 space-y-4"> <div> <p className="text-sm font-medium text-zinc-500">Discogs collection</p> <h1 className="text-3xl font-bold tracking-tight text-zinc-950">Music Explorer</h1> </div> <input aria-label="Search albums or artists" value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search album or artist" className="w-full rounded-xl border border-zinc-300 px-4 py-3 outline-none focus:border-zinc-900 focus:ring-2 focus:ring-zinc-200 sm:max-w-md" /> </header> <section className="grid grid-cols-1 gap-5 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5"> {filteredAlbums.map((album) => ( <Link key={album.id} to={`/albums/${album.id}`} className="rounded-2xl focus:outline-none focus-visible:ring-2 focus-visible:ring-zinc-900" > <AlbumCard album={album} /> </Link> ))} </section> </main>
Exit test

Point to grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 and ask the student to explain what happens as viewport width grows and what “mobile first” means.

Reflection

Answer each in your own words first. If you can, the idea is yours.

Reflect Translate flex, p-4, and md:grid-cols-2 back to what they do.

flexdisplay:flex; p-4 → a padding token; md:grid-cols-2 → two grid columns from the medium breakpoint up. Utilities are just CSS.

Reflect What does “mobile-first” mean for grid-cols-1 md:grid-cols-3?

The base (one column) applies at every width; md: adds three columns only from the medium breakpoint up. You style the small screen first, then layer on larger-screen rules.

Stage 16 · Production
STAGE 16

Add Performance Deliberately, Not Superstitiously

The challenge explicitly asks for performance and optimized API usage. Performance interviews often expose cargo-cult habits such as adding useMemo, useCallback, and debouncing everywhere.
Why this stage, and what to picture

The challenge explicitly asks for performance and optimized API usage. Performance interviews often expose cargo-cult habits such as adding useMemo, useCallback, and debouncing everywhere.

The first principle is:

Optimize the actual expensive boundary first.

For Music Explorer, the expensive boundaries are more likely to be:

  1. network requests,
  2. cover-image loading,
  3. very large DOM lists,
  4. only then small JavaScript calculations.

A useful priority order:

avoid work > do less work > do work later > do the same work faster

Examples:

  • avoid 500 detail calls,
  • paginate the collection,
  • lazy-load images,
  • cache release details,
  • only virtualize if the rendered list is actually large enough to hurt.
Deeper intuition — builds on the last stage

Making it fast is not sprinkling useMemo everywhere. It is finding the real bottleneck and removing work there. For this app the expensive things are network requests and images, not a fifty-item filter.

Order of leverage: avoid work > do less > do it later > do it faster. You already have the big wins from earlier stages — fetch on demand (11), paginate (13), cache (14) — plus lazy-loaded images. Reach for memoization only when you have measured a real cost; adding it blindly just makes the code harder for no gain.

How it runs in this exercise: call → render → paint

same render loop, but fewer / cheaper calls: • <img loading="lazy"> → the browser defers off-screen image requests • re-open a cached release → render from cache, NO fetch, instant paint • one page request at a time → less data per render (the wins are in the calls and the browser's work, not the render mechanics)
Senior interview lens

Measure first. Use the Profiler, know what actually triggers a re-render (state, props, context), and understand the memo toolkit — React.memo, useMemo, useCallback — is about referential stability and has real costs. Rank the wins: network > images > large lists (virtualization) > JavaScript. Premature memoization is a senior anti-signal; “I profiled it” is the opposite.

Target design — how the app should look after this stage

No new screen. The wins are lazy-loaded images, cached details, and pagination already in place.

localhost:5173

Music Explorer

cover
Purple Rain
Prince
1984
cover
Thriller
Michael Jackson
1982
cover
Rumours
Fleetwood Mac
1977
cover
Kind of Blue
Miles Davis
1959
cover
Blue
Joni Mitchell
1971
cover
Nevermind
Nirvana
1991

Invisible by design: loading="lazy" covers · cached release details · page-at-a-time requests.

Build target

Add low-cost, evidence-based performance measures and write down what you would measure next.

Interview prep

Answer each out loud first, then open to check.

Q Would you debounce the current local filter?

“Probably not for a few hundred already-loaded albums because local string filtering is cheap. I would debounce if each keystroke triggered a remote search or profiling showed real input lag.”

Q When would you use useMemo?

“When profiling shows an expensive pure computation or when stable identity is materially needed for a memoized dependency. It is a performance tool, not a correctness tool.”

Q What is the biggest API optimization in this design?

“Fetch lightweight collection pages, then fetch release details only on navigation and cache them by release ID.”

Bugs to watch for
Bug Memoizing everything and increasing cognitive complexity without measurable benefit.

Memoizing everything and increasing cognitive complexity without measurable benefit.

Bug Adding a debounce timer and forgetting cleanup, leading to outdated updates.

Adding a debounce timer and forgetting cleanup, leading to outdated updates.

Bug Lazy-loading above-the-fold critical images indiscriminately, hurting perceived initial rendering.

Lazy-loading above-the-fold critical images indiscriminately, hurting perceived initial rendering.

Scope guard — what NOT to add yet

Do not add list virtualization unless you reproduce a real large-list rendering problem.

Scaffolding dilution

Student must state the suspected bottleneck and measurement before choosing an optimization.

Checkpoint solution

An answer key after your own attempt — never a thing to copy first.

Solution Full checkpoint code

The existing architecture already contains the most valuable optimizations:

<img src={album.coverUrl ?? undefined} alt={`${album.title} cover`} loading="lazy" className="aspect-square w-full object-cover" />

Detail queries are cached by release ID:

useQuery({ queryKey: ['release', id], queryFn: () => getReleaseDetails(id), staleTime: 60 * 60 * 1000, });

Collection pages are requested by page rather than all at once:

useQuery({ queryKey: ['collection', '9000RPM', page], queryFn: () => getCollectionPage('9000RPM', page, 50), });

A useful optional prefetch, only after the basics are correct, is to prefetch a release when the user strongly signals intent, such as focusing or hovering a card. This should be treated as an enhancement, not Stage 1 architecture.

Exit test

Ask the student to rank these optimizations for this app:

  • useMemo around a 50-item filter,
  • lazy detail fetching,
  • caching release details,
  • lazy-loading off-screen cover art.

They should prioritize network and image work over tiny local computation.

Reflection

Answer each in your own words first. If you can, the idea is yours.

Reflect An interviewer says “make this faster.” What do you do before adding useMemo?

Measure — profile to find the real bottleneck. For this app that is network and images, not a small filter. Optimize the actual expensive boundary; memoize only a measured cost.

Reflect Rank for this app: memoizing a 50-item filter, caching release details, lazy-loading covers.

Caching details and lazy images matter most (network and image work); memoizing a 50-item filter is negligible and usually not worth the added complexity.

Stage 17 · Production
STAGE 17

Test User Behavior, Not Implementation Trivia

A UI challenge is easier to trust when the key behaviors are executable specifications.
Why this stage, and what to picture

A UI challenge is easier to trust when the key behaviors are executable specifications.

The testing goal is not “reach 100% coverage.” It is:

Prove the behaviors that would embarrass us in an interview demo if they broke.

For this app those are:

  • filtering by album,
  • filtering by artist,
  • loading/error behavior,
  • navigating to a release,
  • rendering track data.

Good frontend test:

Given visible UI/data When user performs behavior Then user observes result

Weak test:

Given internal function name Assert private implementation detail
Deeper intuition — builds on the last stage

Trust the app by testing what the user does, not how the code is written. A good test reads like a sentence about behavior: given the albums on screen, when the user types “prince,” then only Prince shows.

Test through what the user can see (roles and labels) and fake the network so tests are fast and deterministic. Tests tied to internal details break on harmless refactors and teach you to distrust them. Notice you only extracted the filter into its own function now, when it is worth testing: need drives the abstraction, not habit (in Stage 6 it was rightly inline).

How it runs in this exercise: call → render → paint

(simulated in jsdom, not a real browser) test renders <App/> → React builds the DOM inside jsdom → userEvent.type(input, 'prince') → fires input events → onChange → setSearch → re-render → the jsdom DOM updates → the test queries that DOM and asserts what the user would see (no real paint; jsdom stands in for the browser's DOM)
Senior interview lens

Favor the testing trophy: mostly integration tests, driven through accessible queries (getByRole), with the network mocked at the boundary (MSW). A senior treats tests as a design tool and a refactoring safety net, refuses to test implementation details, and can answer the sharper question: “what would you not test here, and why?”

Target design — how the app should look after this stage

Not a screen, the safety net: behavior tests that would catch a broken filter before a demo.

vitest
filterAlbums › matches album title case-insensitively
filterAlbums › matches artist name case-insensitively
filterAlbums › returns all albums for an empty query
Test Files 1 passed (1)  Tests 3 passed (3)
Build target

A typical Vite React testing setup uses Vitest, Testing Library, and a DOM environment:

npm install -D vitest jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event

Exact config can vary with the current Vite template, so keep testing setup isolated from the conceptual lesson.

Interview prep

Answer each out loud first, then open to check.

Q Unit test or integration test for filtering?

“The filter function can be unit-tested, but a lightweight component test gives more value because it verifies the input event, state update, derivation, and rendered result together.”

Q Should tests call the real Discogs API?

“Not for deterministic automated tests. Mock the network boundary and use known fixtures. Separately, keep a small manual/integration smoke test against the real service.”

Bugs to watch for
Bug Selecting DOM nodes by fragile CSS classes instead of accessible roles/labels.

Selecting DOM nodes by fragile CSS classes instead of accessible roles/labels.

Bug Making automated tests depend on live Discogs data that can change or rate-limit.

Making automated tests depend on live Discogs data that can change or rate-limit.

Bug Testing that internal state equals a value rather than asserting the UI behavior caused by that state.

Testing that internal state equals a value rather than asserting the UI behavior caused by that state.

Scope guard — what NOT to add yet

Do not introduce end-to-end infrastructure until the core component/integration tests are understood.

Scaffolding dilution

Instructor gives behavior in Given/When/Then form. Student writes the test.

Checkpoint solution

An answer key after your own attempt — never a thing to copy first.

Solution Full checkpoint code

Example: src/filterAlbums.ts

import type { Album } from './types'; export function filterAlbums(albums: Album[], search: string): Album[] { const query = search.trim().toLowerCase(); if (!query) return albums; return albums.filter((album) => `${album.title} ${album.artist}`.toLowerCase().includes(query), ); }

Example: src/filterAlbums.test.ts

import { describe, expect, it } from 'vitest'; import { filterAlbums } from './filterAlbums'; import type { Album } from './types'; const albums: Album[] = [ { id: 1, title: 'Purple Rain', artist: 'Prince', year: 1984, label: 'Warner', coverUrl: null }, { id: 2, title: 'Thriller', artist: 'Michael Jackson', year: 1982, label: 'Epic', coverUrl: null }, ]; describe('filterAlbums', () => { it('matches album title case-insensitively', () => { expect(filterAlbums(albums, 'purple')).toEqual([albums[0]]); }); it('matches artist name case-insensitively', () => { expect(filterAlbums(albums, 'MICHAEL')).toEqual([albums[1]]); }); it('returns all albums for an empty query', () => { expect(filterAlbums(albums, ' ')).toEqual(albums); }); });

Then use the same function in CollectionPage:

const filteredAlbums = filterAlbums(albums, search);

This extraction is justified because the behavior now has independent value and is easy to test. It was not necessary in Stage 6.

Exit test

Ask the student to write one Given/When/Then test verbally for “filter by artist” before writing any test code.

Reflection

Answer each in your own words first. If you can, the idea is yours.

Reflect Why query by role/label instead of CSS classes or internal state?

Role and label queries assert what the user actually experiences and survive refactors. Tests coupled to classes or internal state break on harmless changes and test implementation, not behavior.

Reflect Should the filter test hit the real Discogs API?

No — mock the network boundary for fast, deterministic tests, and keep the real API for a separate manual smoke test. Live data can change or rate-limit.

Stage 18 · Production
STAGE 18

Protect Credentials and Understand the Browser/API Boundary

The public Discogs collection can be useful for the challenge, but real API integrations often require credentials.
Why this stage, and what to picture

The public Discogs collection can be useful for the challenge, but real API integrations often require credentials.

A critical frontend security principle is:

If JavaScript running in the browser can read a secret, the user can read it too.

Vite’s VITE_* variables are intended for values that may be exposed to client code. They are not a secure place for private API tokens.

Unsafe:

browser bundle | └── private token <- user can inspect this

Safer:

browser | | GET /api/collection v our server/serverless function | | adds private credential v Discogs

The backend is not being added because React “needs a backend.” It is added when we need a trusted execution boundary.

Deeper intuition — builds on the last stage

If a real Discogs token were required, it could not live in the browser. Anything the browser can read, the user can read, and build-time env vars get baked into the bundle they download (remember the build-time vs run-time split from Stage 0). A secret needs a trusted place to run: a small server.

So the call path grows a hop: browser → your server (holds the token) → Discogs → back. The render-and-paint half you learned is unchanged; you have only added a trusted middle. You add a backend when you need trust, not because “React needs one.”

How it runs in this exercise: call → render → paint

browser: fetch('/api/collection') ← no secret in the browser → YOUR server route runs → it calls Discogs with the secret Authorization header → returns JSON to the browser → setState → render → the browser paints (the render/paint half is unchanged; a trusted server is added to the call path)
Senior interview lens

One rule: never trust the client. Cover the backend-for-frontend / proxy pattern, secret management, CORS vs same-origin, SSRF from an open proxy, and XSS (why React escapes by default, and why dangerouslySetInnerHTML is a loaded gun). Authentication and authorization live on the server. The probe is always “where does the token live, and who can read it?”

Target design — how the app should look after this stage

Not a screen, but where the secret is allowed to live: on a trusted server, never in the browser bundle.

Browserno secret
Your serverholds the token
Discogs
Build target

For a public test collection, keep the client read-only and token-free. If authenticated Discogs access becomes necessary, move the credential and upstream request into a tiny backend/serverless route.

Interview prep

Answer each out loud first, then open to check.

Q Can I hide a secret in .env in a Vite app?

“A local .env file keeps a value out of Git, but if that value is injected into browser code it is still exposed to users. Secrets need to remain on a trusted server.”

Q Why use a backend proxy?

“To keep credentials private, enforce our own authorization/rate limits, normalize upstream responses, and centralize retries or caching.”

Q What about CORS?

“CORS is a browser security policy controlled by response headers. A server-to-server request is not governed by browser CORS in the same way, so a backend can also provide a stable same-origin API boundary.”

Bugs to watch for
Bug Putting VITE_DISCOGS_TOKEN into client code and assuming .env makes it secret.

Putting VITE_DISCOGS_TOKEN into client code and assuming .env makes it secret.

Bug Committing a real token into Git history.

Committing a real token into Git history.

Bug Building an unrestricted proxy that accepts arbitrary upstream URLs, accidentally creating an abuse/SSRF surface.

Building an unrestricted proxy that accepts arbitrary upstream URLs, accidentally creating an abuse/SSRF surface.

Scope guard — what NOT to add yet

Do not build a full Express application just to demonstrate the public read-only collection. Add a trusted server boundary only when credentials or product requirements justify it.

Scaffolding dilution

Student draws where code executes: browser versus server. They should decide where a secret is allowed to exist.

Checkpoint solution

An answer key after your own attempt — never a thing to copy first.

Solution Full checkpoint code

Public-read challenge mode

Use the existing browser fetch code with no secret.

Authenticated production-style pseudocode

A serverless/API route would own the secret:

export async function GET() { const response = await fetch( 'https://api.discogs.com/users/9000RPM/collection/folders/0/releases', { headers: { Authorization: `Discogs token=${process.env.DISCOGS_TOKEN}`, 'User-Agent': 'MusicExplorer/1.0', }, }, ); if (!response.ok) { return new Response('Upstream Discogs request failed', { status: 502 }); } return Response.json(await response.json()); }

The exact hosting framework is intentionally unspecified because the security principle is the lesson.

Exit test

Ask: “Why is VITE_PRIVATE_API_KEY a misleading variable name?”

Expected answer: because anything intentionally exposed to Vite client code is not private from the browser user.

Reflection

Answer each in your own words first. If you can, the idea is yours.

Reflect Why isn’t putting a token in a VITE_ variable enough to keep it secret?

VITE_ vars are inlined into the client bundle the user downloads; anything the browser can read, the user can read. A secret must stay on a trusted server.

Reflect Where do the token and authorization belong, and how does the call path change?

On a server. The browser calls your server (no secret), which calls Discogs with the token and returns data. Authentication and authorization live server-side, never in the client.

Stage 19 · Production
STAGE 19

Treat Genius Lyrics as an Isolated Bonus Integration

The challenge labels lyrics as a bonus. That should affect project sequencing.
Why this stage, and what to picture

The challenge labels lyrics as a bonus. That should affect project sequencing.

A bonus API should not destabilize the core requirements:

collection REQUIRED release details REQUIRED artist/title filter REQUIRED lyrics BONUS

There is also an integration-design concern: Genius’s documented API is centered on metadata/search and Genius resources; obtaining and redistributing full lyrics is not the same as simply calling a documented “lyrics text” endpoint. Full lyrics also raise licensing/copyright concerns.

Therefore the safe interview-ready interpretation is:

  1. search Genius for the track,
  2. obtain the matched Genius song/page URL and metadata,
  3. offer “View lyrics on Genius,”
  4. only display full lyrics if the product has a legitimate, documented/licensed source for them.

Do not couple Discogs and Genius data models.

Discogs track | | title + artist v lyrics lookup boundary | v Genius match metadata/link

The core app remains functional even if Genius is unavailable.

Deeper intuition — builds on the last stage

Lyrics are a bonus and a separate outside system, so they must never be able to break the album page. Wrap them behind their own small boundary, a LyricsLink component, in the same spirit as the adapter boundary from Stage 7.

Design the failure first: if the lyrics source is down or unavailable, the album page still works and the link simply does nothing special. Optional dependencies never sit on the critical path. “What happens if this is down?” is the design question for every integration you add.

How it runs in this exercise: call → render → paint

album page render → the tracklist includes an <a> "Find lyrics" → clicking it is the BROWSER's own navigation (opens Genius / a search) → it does NOT trigger a React render in your app (if lyrics were fetched instead: its own effect → setState → render, fully isolated from the album's data)
Senior interview lens

Design for failure and change: isolate the third party behind an interface, define the degraded state first (“Genius is down — now what?”), and treat rate limits and licensing as design constraints, not details. A senior builds the seam so the vendor is swappable and never lets an optional dependency sit on the critical path.

Target design — how the app should look after this stage

Each track gains an optional Find lyrics link, a bonus that never blocks the core app.

localhost:5173/albums/249504
Purple Rain
Prince · 1984
Tracks
  1. A1 · Let’s Go Crazy Find lyrics
  2. A2 · Take Me With U Find lyrics
  3. B1 · When Doves Cry Find lyrics
Build target

Add a LyricsLink component behind the track-list UI without making it a dependency of album rendering.

Interview prep

Answer each out loud first, then open to check.

Q How would you match tracks across two APIs?

“Use normalized artist + track title as the initial query, then verify the best result instead of assuming the first match is always correct. Titles can differ by punctuation, featured artists, remasters, or versions.”

Q What happens if Genius fails?

“The album page should still work. Lyrics are an optional secondary integration with its own loading/error state.”

Q Would you request Genius data for every track immediately?

“No. I would fetch on explicit user intent, such as pressing ‘Lyrics,’ to minimize request volume.”

Bugs to watch for
Bug Automatically making a Genius request for every track as soon as album details load.

Automatically making a Genius request for every track as soon as album details load.

Bug Assuming the first search result is the correct song/version.

Assuming the first search result is the correct song/version.

Bug Scraping/displaying full copyrighted lyrics without confirming an appropriate allowed/licensed mechanism.

Scraping/displaying full copyrighted lyrics without confirming an appropriate allowed/licensed mechanism.

Scope guard — what NOT to add yet

Do not touch this stage until the required challenge is polished and tested.

Scaffolding dilution

Student designs the failure behavior first: “What happens to the album page if Genius is down?”

Checkpoint solution

An answer key after your own attempt — never a thing to copy first.

Solution Full checkpoint code

A safe UI boundary can start as:

src/components/LyricsLink.tsx

type LyricsLinkProps = { artist: string; trackTitle: string; }; export default function LyricsLink({ artist, trackTitle }: LyricsLinkProps) { const query = encodeURIComponent(`${artist} ${trackTitle} Genius`); return ( <a href={`https://www.google.com/search?q=${query}`} target="_blank" rel="noreferrer" className="text-sm underline" > Find lyrics </a> ); }

This is not the final Genius API integration. It is the correct component boundary. When legitimate Genius search access is configured, replace the implementation behind the boundary without changing the track-list architecture.

Exit test

Ask: “Why is lyrics lookup not inside getReleaseDetails()?”

Expected answer: because it is a separate external system, optional feature, failure domain, caching policy, and request lifecycle.

Reflection

Answer each in your own words first. If you can, the idea is yours.

Reflect Why is lyrics lookup NOT inside getReleaseDetails?

It is a separate external system with its own failure, cache, and licensing concerns. Coupling it means a Genius outage breaks the album page. Keep it behind its own boundary so failures are isolated and the vendor is swappable.

Reflect Design the degraded state: Genius is down. What should the user see?

The album page works normally; the lyrics link simply does nothing useful, or is hidden. Optional dependencies never sit on the critical path.

Stage 20 · Production
STAGE 20

Final Integrated Solution

The final stage is not about adding one more feature. It is about seeing how all of the first principles compose without losing their boundaries.
Why this stage, and what to picture

The final stage is not about adding one more feature. It is about seeing how all of the first principles compose without losing their boundaries.

The student should now be able to look at the final code and identify the reason for every major piece:

React state -> local interaction React Router -> navigation state TanStack Query -> server state/cache API adapter -> third-party boundary Tailwind -> styling system TypeScript -> explicit contracts Tests -> behavior confidence

If the student cannot explain why a library exists, the final stack is too magical.

Deeper intuition — builds on the last stage

Nothing new here; the point is that all the layers compose without losing their boundaries. Each library owns one concern: React describes the UI from state, Router owns URL and navigation state, TanStack Query owns server state, the adapter owns the third-party boundary, Tailwind owns styling, TypeScript writes the contracts, and tests confirm behavior.

That mapping is the whole course in one breath. If you can point at any piece and name the one problem it solves and the pain that came before it, you are not memorizing a stack — you are making decisions you can defend. That is the difference between “I used a starter template” and “I understand what I built.”

How it runs in this exercise: call → render → paint

any user event (type / click a card / paginate) → a handler runs → setState (local) OR Router (URL) OR Query (server data) → React re-renders only the affected components → React diffs the new description against the old and edits the DOM → the browser paints (each library owns one kind of "what triggers a render": useState = local, Router = the URL, TanStack Query = server data)
Senior interview lens

Senior is the ability to reason about the whole system as boundaries and ownership: which layer owns which state, how data flows, where the seams are, and why each dependency earns its place. Be ready to name what you would revisit at ten times the scale or team size — SSR / RSC, code-splitting, error boundaries, observability — and what you would deliberately not add yet. That judgement, not syntax, is what the title tests.

Target design — how the app should look after this stage

The finished Music Explorer: search, a responsive grid, detail pages, cached data, and tests.

localhost:5173
Discogs collection

Music Explorer

cover
Purple Rain
Prince
1984
cover
Thriller
Michael Jackson
1982
cover
Rumours
Fleetwood Mac
1977
cover
Kind of Blue
Miles Davis
1959
cover
Blue
Joni Mitchell
1971
cover
Nevermind
Nirvana
1991
Build target
npm create vite@latest music-explorer -- --template react-ts cd music-explorer npm install npm install react-router-dom @tanstack/react-query npm install tailwindcss @tailwindcss/vite npm install -D vitest jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event npm run dev
Checkpoint solution

An answer key after your own attempt — never a thing to copy first.

Solution Full checkpoint code

src/types.ts

export type Album = { id: number; title: string; artist: string; year: number | null; label: string; coverUrl: string | null; }; export type Track = { position: string; title: string; duration: string; }; export type AlbumDetails = Album & { released: string; tracks: Track[]; };

src/api/discogs.ts

import type { Album, AlbumDetails } from '../types'; const API_BASE = 'https://api.discogs.com'; type CollectionResponse = { pagination: { page: number; pages: number; per_page: number; items: number; }; releases: Array<{ basic_information: { id: number; title: string; year: number; cover_image?: string; artists?: Array<{ name: string }>; labels?: Array<{ name: string }>; }; }>; }; type ReleaseResponse = { id: number; title: string; year: number; released?: string; images?: Array<{ uri?: string }>; artists?: Array<{ name: string }>; labels?: Array<{ name: string }>; tracklist?: Array<{ position?: string; title: string; duration?: string; }>; }; export async function getCollectionPage( username: string, page = 1, perPage = 50, ): Promise<{ albums: Album[]; page: number; pages: number; total: number }> { const url = new URL( `${API_BASE}/users/${encodeURIComponent(username)}/collection/folders/0/releases`, ); url.searchParams.set('page', String(page)); url.searchParams.set('per_page', String(perPage)); const response = await fetch(url); if (!response.ok) { throw new Error(`Discogs collection request failed: ${response.status}`); } const data: CollectionResponse = await response.json(); return { albums: data.releases.map(({ basic_information: info }) => ({ id: info.id, title: info.title, artist: info.artists?.[0]?.name ?? 'Unknown artist', year: info.year || null, label: info.labels?.[0]?.name ?? 'Unknown label', coverUrl: info.cover_image ?? null, })), page: data.pagination.page, pages: data.pagination.pages, total: data.pagination.items, }; } export async function getReleaseDetails(id: number): Promise<AlbumDetails> { const response = await fetch(`${API_BASE}/releases/${id}`); if (!response.ok) { throw new Error(`Discogs release request failed: ${response.status}`); } const data: ReleaseResponse = await response.json(); return { id: data.id, title: data.title, artist: data.artists?.[0]?.name ?? 'Unknown artist', year: data.year || null, label: data.labels?.[0]?.name ?? 'Unknown label', coverUrl: data.images?.[0]?.uri ?? null, released: data.released ?? 'Unknown release date', tracks: (data.tracklist ?? []).map((track) => ({ position: track.position ?? '', title: track.title, duration: track.duration ?? '', })), }; }

src/filterAlbums.ts

import type { Album } from './types'; export function filterAlbums(albums: Album[], search: string): Album[] { const query = search.trim().toLowerCase(); if (!query) return albums; return albums.filter((album) => `${album.title} ${album.artist}`.toLowerCase().includes(query), ); }

src/components/AlbumCard.tsx

import type { Album } from '../types'; type AlbumCardProps = { album: Album; }; export default function AlbumCard({ album }: AlbumCardProps) { return ( <article className="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm transition hover:-translate-y-0.5 hover:shadow-md"> {album.coverUrl ? ( <img src={album.coverUrl} alt={`${album.title} cover`} loading="lazy" className="aspect-square w-full object-cover" /> ) : ( <div className="grid aspect-square place-items-center bg-zinc-100 text-sm text-zinc-500"> No cover </div> )} <div className="space-y-1 p-4"> <h2 className="line-clamp-2 font-semibold text-zinc-950">{album.title}</h2> <p className="truncate text-sm text-zinc-600">{album.artist}</p> <p className="text-xs text-zinc-500">{album.year ?? 'Year unknown'}</p> </div> </article> ); }

src/components/AlbumDetail.tsx

import { useQuery } from '@tanstack/react-query'; import { getReleaseDetails } from '../api/discogs'; export default function AlbumDetail({ id }: { id: number }) { const releaseQuery = useQuery({ queryKey: ['release', id], queryFn: () => getReleaseDetails(id), staleTime: 60 * 60 * 1000, }); if (releaseQuery.isPending) { return <p className="py-8 text-zinc-600">Loading album details…</p>; } if (releaseQuery.isError) { return <p role="alert" className="py-8">{releaseQuery.error.message}</p>; } const album = releaseQuery.data; return ( <article className="mt-6"> <div className="grid gap-8 md:grid-cols-[280px_1fr]"> {album.coverUrl ? ( <img src={album.coverUrl} alt={`${album.title} cover`} className="aspect-square w-full rounded-2xl object-cover shadow-sm" /> ) : ( <div className="grid aspect-square place-items-center rounded-2xl bg-zinc-100"> No cover </div> )} <div> <p className="text-sm font-medium text-zinc-500">{album.year ?? 'Year unknown'}</p> <h1 className="mt-1 text-4xl font-bold tracking-tight">{album.title}</h1> <p className="mt-2 text-lg text-zinc-700">{album.artist}</p> <dl className="mt-6 grid gap-2 text-sm"> <div><dt className="inline font-semibold">Label: </dt><dd className="inline">{album.label}</dd></div> <div><dt className="inline font-semibold">Released: </dt><dd className="inline">{album.released}</dd></div> </dl> </div> </div> <section className="mt-10"> <h2 className="text-2xl font-semibold">Tracks</h2> {album.tracks.length === 0 ? ( <p className="mt-4 text-zinc-600">No track list available.</p> ) : ( <ol className="mt-4 divide-y divide-zinc-200"> {album.tracks.map((track, index) => ( <li key={`${track.position}-${track.title}-${index}`} className="grid grid-cols-[3rem_1fr_auto] gap-3 py-3 text-sm" > <span className="text-zinc-500">{track.position || index + 1}</span> <span>{track.title}</span> <span className="text-zinc-500">{track.duration}</span> </li> ))} </ol> )} </section> </article> ); }

src/pages/CollectionPage.tsx

import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { Link } from 'react-router-dom'; import { getCollectionPage } from '../api/discogs'; import AlbumCard from '../components/AlbumCard'; import { filterAlbums } from '../filterAlbums'; const USERNAME = '9000RPM'; export default function CollectionPage() { const [search, setSearch] = useState(''); const [page, setPage] = useState(1); const collectionQuery = useQuery({ queryKey: ['collection', USERNAME, page], queryFn: () => getCollectionPage(USERNAME, page, 50), staleTime: 5 * 60 * 1000, }); if (collectionQuery.isPending) { return <main className="mx-auto max-w-7xl p-6">Loading collection…</main>; } if (collectionQuery.isError) { return ( <main className="mx-auto max-w-7xl p-6" role="alert"> Could not load collection: {collectionQuery.error.message} </main> ); } const { albums, pages, total } = collectionQuery.data; const filteredAlbums = filterAlbums(albums, search); return ( <main className="mx-auto min-h-screen max-w-7xl px-4 py-8 sm:px-6 lg:px-8"> <header className="mb-8 space-y-4"> <div> <p className="text-sm font-medium text-zinc-500">{total} releases in collection</p> <h1 className="text-3xl font-bold tracking-tight">Music Explorer</h1> </div> <input aria-label="Search albums or artists" value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search album or artist" className="w-full rounded-xl border border-zinc-300 px-4 py-3 outline-none focus:border-zinc-900 focus:ring-2 focus:ring-zinc-200 sm:max-w-md" /> </header> {filteredAlbums.length === 0 ? ( <p>No albums on this page match “{search}”.</p> ) : ( <section className="grid grid-cols-1 gap-5 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5"> {filteredAlbums.map((album) => ( <Link key={album.id} to={`/albums/${album.id}`} className="rounded-2xl focus:outline-none focus-visible:ring-2 focus-visible:ring-zinc-900" > <AlbumCard album={album} /> </Link> ))} </section> )} <nav className="mt-8 flex items-center gap-3" aria-label="Collection pages"> <button onClick={() => setPage((current) => current - 1)} disabled={page === 1} className="rounded-lg border px-3 py-2 disabled:opacity-40" > Previous </button> <span className="text-sm text-zinc-600">Page {page} of {pages}</span> <button onClick={() => setPage((current) => current + 1)} disabled={page === pages} className="rounded-lg border px-3 py-2 disabled:opacity-40" > Next </button> </nav> </main> ); }

src/pages/AlbumPage.tsx

import { Link, useParams } from 'react-router-dom'; import AlbumDetail from '../components/AlbumDetail'; export default function AlbumPage() { const { id } = useParams(); const releaseId = Number(id); if (!Number.isInteger(releaseId) || releaseId <= 0) { return ( <main className="mx-auto max-w-5xl p-6"> <p>Invalid album ID.</p> <Link to="/" className="underline">Back to collection</Link> </main> ); } return ( <main className="mx-auto min-h-screen max-w-5xl px-4 py-8 sm:px-6"> <Link to="/" className="text-sm font-medium underline">← Back to collection</Link> <AlbumDetail id={releaseId} /> </main> ); }

src/App.tsx

import { Route, Routes } from 'react-router-dom'; import AlbumPage from './pages/AlbumPage'; import CollectionPage from './pages/CollectionPage'; export default function App() { return ( <Routes> <Route path="/" element={<CollectionPage />} /> <Route path="/albums/:id" element={<AlbumPage />} /> <Route path="*" element={<main className="p-6"><h1>Not found</h1></main>} /> </Routes> ); }

src/main.tsx

import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import './index.css'; import App from './App'; const queryClient = new QueryClient({ defaultOptions: { queries: { retry: 1, }, }, }); createRoot(document.getElementById('root')!).render( <StrictMode> <QueryClientProvider client={queryClient}> <BrowserRouter> <App /> </BrowserRouter> </QueryClientProvider> </StrictMode>, );

src/index.css

@import "tailwindcss"; html { background: #fafafa; color: #18181b; } body { margin: 0; } a { color: inherit; text-decoration: none; }

vite.config.ts

import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import tailwindcss from '@tailwindcss/vite'; export default defineConfig({ plugins: [react(), tailwindcss()], });
Reflection

Answer each in your own words first. If you can, the idea is yours.

Reflect For React, Router, TanStack Query, and the adapter, name the one problem each solves.

React: describe the UI from state. Router: keep navigation state in the URL. TanStack Query: manage server state and its cache. Adapter: isolate the third-party shape at a single boundary.

Reflect What separates “I used a starter template” from “I understand what I built”?

Being able to point at each dependency and state the problem it solves and the pain that came before it — choosing tools deliberately and defending the tradeoffs, rather than accepting a boilerplate.

Reflect What would you revisit at ten times the data or team size?

Things like SSR / RSC for first load and SEO, code-splitting, error boundaries, observability, and possibly cursor pagination plus list virtualization — added when the scale justifies them, not before.

Finish line
The real exit testRebuild the whole app from a blank folder with the solutions closed. If you can explain, for every library in the final stack, the exact problem it solves and the pain you felt before adding it, the stack is no longer magic — it is a set of decisions you can defend in an interview.

Progress and theme are saved only in this browser, on this device. Built for Pristone Academy.