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.
The challenge sounds like one application, but it is actually a bundle of smaller ideas:
The curriculum therefore grows the app in layers. Each stage contains:
The student should not memorize completed code. The final code in each stage is an answer key after an attempt.
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:
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:
React is not the web server. Vite is not React. npm is not React either.
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
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?”
Just proof the pipeline works: your own text on screen, hot-reloading as you edit.
The Vite starter, replaced by your own heading. Edit App.tsx and this updates instantly.
Create a React + TypeScript Vite app and prove that changing App.tsx changes the page.
Commands
Answer each out loud first, then open to check.
“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.”
“The API gives us structured data. Types make the expected shape explicit and catch mismatches earlier, especially around optional fields and nested API responses.”
npm run dev from the wrong directory.The terminal must be inside music-explorer.
Vite’s React template renders src/App.tsx from src/main.tsx.
Read the first actual error and the file/line that caused it.
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.
An answer key after your own attempt — never a thing to copy first.
src/App.tsx
src/main.tsx
The student can answer:
<App />?Answer each in your own words first. If you can, the idea is yours.
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.
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.
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 is a convenient syntax for describing a UI tree. It is not a separate webpage language.
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
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.
One album card, hand-written in JSX with plain CSS. No data, no state yet.
Render one album with a title, artist, year, and a visual placeholder.
Interview prepAnswer each out loud first, then open to check.
“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.”
“A component returns a single React value/tree. Multiple siblings can be wrapped in a parent element or Fragment.”
class instead of React’s className.Using class instead of React’s className.
<img />.Forgetting to close JSX tags such as <img />.
Returning adjacent top-level elements without a wrapper or Fragment.
Use plain CSS. Do not introduce Tailwind yet. The student should see the direct relationship between a CSS rule and a DOM element.
An answer key after your own attempt — never a thing to copy first.
src/App.tsx
src/App.css
The student can draw the DOM tree:
Answer each in your own words first. If you can, the idea is yours.
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.
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.
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.
The UI is no longer the source of truth for the title. The object is.
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
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.
Identical pixels, but the title / artist / year now come from a data object, not hardcoded JSX.
Same look; the object is now the source of truth.
Represent one album as a JavaScript object and render its properties.
Interview prepAnswer each out loud first, then open to check.
“They describe one entity and usually travel together. It lets us pass, transform, type, and render the album as one unit.”
“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.”
{album} directly in JSXReact cannot render an arbitrary object as text.
album.artst and getting undefined.Misspelling a property such as album.artst and getting undefined.
Assuming mutating a normal object automatically triggers a React render.
No useState yet. The only idea is data -> UI.
An answer key after your own attempt — never a thing to copy first.
src/App.tsx
Use the same App.css from Stage 1.
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.
Grid version (from the checkpoint):
Flexbox alternative — identical result:
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.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.
Answer each in your own words first. If you can, the idea is yours.
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.
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.
AlbumCard and Learn PropsWe 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:
Props are simply the inputs to that component.
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
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.
Still one card, now produced by a reusable
One <AlbumCard />, ready to be reused for many albums.
Create an AlbumCard component and pass album information to it.
Answer each out loud first, then open to check.
“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.”
“Because the same UI rule will be repeated for many albums and should have one implementation.”
AlbumCard() instead of rendering `<AlbumCard ../>` in ordinary component composition.
Mutating props inside the child.
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.
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.
An answer key after your own attempt — never a thing to copy first.
src/components/AlbumCard.tsx
src/App.tsx
The student can explain where the data lives and which direction it moves.
Answer each in your own words first. If you can, the idea is yours.
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.
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.
mapThe 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.
map means:
take every item and transform it into something else.
React simply knows how to render the resulting list of elements.
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
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.
A collection: three albums rendered by mapping an array to
Render three fake albums using .map().
Answer each out loud first, then open to check.
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.”
.map() mutate the original array?No. It creates a new array from the callback results.
return when using braces in a map callback.Forgetting return when using braces in a map callback.
key={index} for reorderable/filterable data when a stable id exists.Using key={index} for reorderable/filterable data when a stable id exists.
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.
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.
An answer key after your own attempt — never a thing to copy first.
src/App.tsx
Add to src/App.css
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.
Grid version (from the checkpoint):
Flexbox alternative:
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.Give the student one album object and ask them to manually show what the map callback returns for that one object.
Answer each in your own words first. If you can, the idea is yours.
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.
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.
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:
That is exactly what React state provides.
React’s documentation describes state as a component’s memory.
This is the central React loop.
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
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.
A controlled search box appears. It stores what you type but does not filter the list yet.
Current search: (empty)
Add a controlled input whose current value is stored in search state. Do not filter yet.
Answer each out loud first, then open to check.
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.”
“React state is the source of truth for value, and changes flow through an event handler that updates that state.”
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.”
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.
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.
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.
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.
An answer key after your own attempt — never a thing to copy first.
src/App.tsx
Student draws the full sequence from keyboard event to re-render and can explain why the setter is passed a function in onChange.
Answer each in your own words first. If you can, the idea is yours.
Keystroke → browser input event → onChange → setSearch 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.
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.
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.
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:
Therefore it is derived data, not independent state.
Every render is a fresh calculation:
No effect is needed.
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
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.
Typing now filters the grid by title or artist, case-insensitively, with a live count.
1 album(s)
Filter by album title or artist, case-insensitively.
Interview prepAnswer each out loud first, then open to check.
“Because it is fully derivable from the source list and search string. Storing it would duplicate state and create synchronization bugs.”
useEffect?“There is no external system to synchronize with. It is a pure calculation needed for rendering.”
useMemo here?“Not by default. For a modest collection, filtering is cheap. I would measure before memoizing.”
.toLowerCase() on an optional/undefined API field later without normalizing it.Calling .toLowerCase() on an optional/undefined API field later without normalizing it.
Filtering the original data destructively instead of deriving a new array.
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.
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.
An answer key after your own attempt — never a thing to copy first.
src/App.tsx
Ask the student to explain why filteredAlbums changes even though there is no setFilteredAlbums.
Answer each in your own words first. If you can, the idea is yours.
filteredAlbums.Ask: can I compute it from state I already have? filteredAlbums = albums + search, so yes — derive it during render, do not store it.
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.
Album Type Before Trusting API Databasic_information, artist arrays, label arrays, images, pagination metadata, and collection-instance fields.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.
This creates a boundary:
The adapter absorbs API weirdness so components can stay simple.
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
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.”
Cards now match your own Album type: a cover image (or a placeholder) and the label.
Create an Album type and update AlbumCard to receive one album object instead of several individual props.
Answer each out loud first, then open to check.
“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.”
“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.”
Treating TypeScript as runtime validation of API JSON.
Assuming every release has a first artist, first label, or image.
Copying a giant third-party type into UI components and making them depend on fields they do not use.
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.
An answer key after your own attempt — never a thing to copy first.
src/types.ts
src/components/AlbumCard.tsx
src/App.tsx
Show the student a nested Discogs response and ask: “Which layer should know basic_information exists?”
Expected answer: the API/adapter layer, not AlbumCard.
Answer each in your own words first. If you can, the idea is yours.
One — the adapter. Everything else depends on your Album domain type, not on Discogs’ shape. That is the whole point of the boundary.
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.
fetch() call directly inside a component and mix four concerns:The application now needs real data. Beginners often put a long fetch() call directly inside a component and mix four concerns:
We want to separate data access from rendering first.
HTTP is a conversation:
fetch returns a Promise because the answer arrives later.
The API function should be understandable without React:
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
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.
No visible change. This stage is the Discogs API module, so the app still shows fake data.
Behind the scenes only: getCollectionPage(username) → Promise<Album[]>, with zero React imports.
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:
For a public collection, folder 0 represents the overall collection and is the useful read path for this challenge. The endpoint is paginated.
Answer each out loud first, then open to 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.”
“It keeps third-party field names and optional structures at the boundary, making components stable if our source changes.”
“Native fetch is sufficient for this read-only exercise. I would add another HTTP library only if it solves a concrete need.”
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.
fetch throws for HTTP 404/500.Assuming fetch throws for HTTP 404/500.
VITE_* environment variableVite-exposed variables become client bundle data.
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.
An answer key after your own attempt — never a thing to copy first.
src/api/discogs.ts
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.
Student can explain why the API function returns a Promise and why components should not know about basic_information.
Answer each in your own words first. If you can, the idea is yours.
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.
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.
We now have two separate worlds:
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.
Key distinction: rendering should calculate UI. Networking is a side effect.
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
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).
Real data at last: the collection loads from Discogs when the app first mounts.
Now populated by a real request on mount.
Load page 1 of user 9000RPM when the app mounts.
Answer each out loud first, then open to check.
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.”
“React Strict Mode intentionally remounts/effect-checks development components to expose missing cleanup and unsafe assumptions. Production behavior is different.”
useEffect?“Not necessarily. Framework loaders and server-state libraries can own data fetching. We are using an Effect now to understand the primitive manually.”
Omitting the dependency array and triggering a request after every render.
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.
Setting state after the component has become irrelevant/unmounted without an ignore/cancel strategy.
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.
An answer key after your own attempt — never a thing to copy first.
src/App.tsx
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.
Answer each in your own words first. If you can, the idea is yours.
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.
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.
A response arriving after the component moved on (unmounted, or its inputs changed) and overwriting current state — a stale update, i.e. a race.
Network data is not binary “there” or “not there.” Real UIs have a small state machine:
And success itself might contain zero results.
If the app only renders albums, the user cannot distinguish:
Think in states, not scattered booleans.
For this learning stage we will use simple state variables, but mentally model them as:
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
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.
Four distinct screens the user can finally tell apart.
Show a loading message, an error message, a true empty collection message, and a no-search-results message.
Interview prepAnswer each out loud first, then open to check.
“The request succeeded and the domain result contains no matching items. Error and empty require different user actions and messaging.”
“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.”
loading in an error path.Never resetting loading in an error path.
Displaying “No albums” for one frame before the first request finishes.
Swallowing the actual error and leaving the page blank.
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.
An answer key after your own attempt — never a thing to copy first.
src/App.tsx
Give four scenarios and ask which branch renders:
Answer each in your own words first. If you can, the idea is yours.
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.
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.
status value over separate loading/error booleans?Booleans allow impossible combinations (loading AND error). A single status (loading | success | error) makes illegal states unrepresentable.
The challenge requires track list, artist, label, release date, and other album details.
A tempting implementation is:
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.
This is lazy detail loading.
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
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.
Click an album and its full detail, release date and track list, loads on demand.
Make an album selectable. When selected, fetch /releases/{id} and show a detail panel with track list.
Answer each out loud first, then open to check.
“Most users will inspect only a subset. Request-on-demand lowers startup latency, request volume, and rate-limit pressure.”
“The first detail view has a network wait. We can later cache or prefetch likely next data if measurements justify it.”
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.
tracklist is always present and non-empty.Assuming tracklist is always present and non-empty.
Leaving an older selected album’s details visible while a newer selection is loading, creating a misleading UI.
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.
An answer key after your own attempt — never a thing to copy first.
Add to src/types.ts
Add to src/api/discogs.ts
Update src/components/AlbumCard.tsx
src/components/AlbumDetail.tsx
Conceptual App.tsx change
Keep the Stage 10 collection-loading code and add:
Then render each card with:
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.
Answer each in your own words first. If you can, the idea is yours.
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.
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.
The selection-state version works, but it has a browser problem:
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:
we can express navigation state as:
Now the browser can participate:
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
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.
The selected album now lives in the URL, so it survives refresh and is shareable.
Create:
Answer each out loud first, then open to check.
“It is navigational state. Encoding it in the URL makes the view refreshable, linkable, bookmarkable, and compatible with browser history.”
“A dynamic portion of the URL pattern, such as :id, that the matched route exposes to the component.”
“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.”
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.
Creating a nested clickable element such as a button inside a link with conflicting interaction semantics.
Forgetting a route for unknown paths or invalid release IDs.
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.
An answer key after your own attempt — never a thing to copy first.
src/main.tsx
src/App.tsx
src/pages/CollectionPage.tsx
Move the Stage 10 collection loading/filtering logic into this component and replace card selection with links:
For this stage, return AlbumCard to a display-only component that no longer needs onSelect.
src/pages/AlbumPage.tsx
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.
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.
Answer each in your own words first. If you can, the idea is yours.
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.
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.
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.
This is different from detail fetching:
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
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.
Only page 1 loads first; a Load more button appends the next page.
Load page 1 first, then allow the user to explicitly load the next page until there are no more pages.
Interview prepAnswer each out loud first, then open to check.
“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.”
“That increases initial latency and request volume. Incremental loading is a safer default, especially under rate limits.”
“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.”
Replacing the old list with page 2 instead of appending page 2.
Incrementing the page before a request succeeds and skipping data after an error.
Discogs can represent multiple instances of the same release; the product must decide whether duplicates matter.
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.
An answer key after your own attempt — never a thing to copy first.
Update src/pages/CollectionPage.tsx
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.
Answer each in your own words first. If you can, the idea is yours.
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.
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.
By now the student has manually built:
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:
TanStack Query associates remote data with a stable query key:
If the same query is needed again, the cache can participate instead of blindly starting from zero.
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
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.
Same UI, but the collection and details are now cached server state (TanStack Query).
Move collection and release-detail server state into TanStack Query while keeping search as ordinary React state.
Answer each out loud first, then open to check.
useState?“Search is local UI state owned by this interface. Discogs data is remote server state with loading, caching, staleness, and synchronization concerns.”
“A serializable identity for the remote data. It lets the library cache, share, refetch, and invalidate the correct data.”
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.”
“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.”
Using the same query key for different release IDs, causing cache collisions.
page or id in the query key.Forgetting to include a changing dependency such as page or id in the query key.
Assuming cache means “never request again,” ignoring staleness/refetch policies.
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.
An answer key after your own attempt — never a thing to copy first.
src/main.tsx
src/pages/CollectionPage.tsx
src/components/AlbumDetail.tsx
src/pages/AlbumPage.tsx
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.
Answer each in your own words first. If you can, the idea is yours.
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.
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.
The hand-built loading/error state, the fetching effect, stale-response guarding, and page caching from Stages 9, 10, and 13.
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:
The student should be able to translate a utility back into its CSS purpose.
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
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.
The same app, now with a consistent, responsive design system.
Create a responsive album grid, consistent card spacing, clear hover/focus states, and a readable detail page.
vite.config.ts
src/index.css
Answer each out loud first, then open to check.
“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.”
“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.”
“Either explicit breakpoints or an auto-fit/minmax grid strategy. With Tailwind I can use responsive column utilities while preserving a mobile-first base.”
Styling only mouse hover and forgetting keyboard focus visibility.
Giving images fixed dimensions that distort cover art instead of using aspect ratio/object-fit.
Copying enormous utility strings everywhere instead of extracting a repeated component.
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.
An answer key after your own attempt — never a thing to copy first.
src/components/AlbumCard.tsx
Key layout from src/pages/CollectionPage.tsx
Replace presentation classes with:
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.
Answer each in your own words first. If you can, the idea is yours.
flex, p-4, and md:grid-cols-2 back to what they do.flex → display:flex; p-4 → a padding token; md:grid-cols-2 → two grid columns from the medium breakpoint up. Utilities are just CSS.
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.
useMemo, useCallback, and debouncing everywhere.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:
A useful priority order:
Examples:
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
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.
No new screen. The wins are lazy-loaded images, cached details, and pagination already in place.
Invisible by design: loading="lazy" covers · cached release details · page-at-a-time requests.
Add low-cost, evidence-based performance measures and write down what you would measure next.
Interview prepAnswer each out loud first, then open to check.
“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.”
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.”
“Fetch lightweight collection pages, then fetch release details only on navigation and cache them by release ID.”
Memoizing everything and increasing cognitive complexity without measurable benefit.
Adding a debounce timer and forgetting cleanup, leading to outdated updates.
Lazy-loading above-the-fold critical images indiscriminately, hurting perceived initial rendering.
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.
An answer key after your own attempt — never a thing to copy first.
The existing architecture already contains the most valuable optimizations:
Detail queries are cached by release ID:
Collection pages are requested by page rather than all at once:
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.
Ask the student to rank these optimizations for this app:
useMemo around a 50-item filter,They should prioritize network and image work over tiny local computation.
Answer each in your own words first. If you can, the idea is yours.
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.
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.
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:
Good frontend test:
Weak test:
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
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?”
Not a screen, the safety net: behavior tests that would catch a broken filter before a demo.
A typical Vite React testing setup uses Vitest, Testing Library, and a DOM environment:
Exact config can vary with the current Vite template, so keep testing setup isolated from the conceptual lesson.
Interview prepAnswer each out loud first, then open to check.
“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.”
“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.”
Selecting DOM nodes by fragile CSS classes instead of accessible roles/labels.
Making automated tests depend on live Discogs data that can change or rate-limit.
Testing that internal state equals a value rather than asserting the UI behavior caused by that state.
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.
An answer key after your own attempt — never a thing to copy first.
Example: src/filterAlbums.ts
Example: src/filterAlbums.test.ts
Then use the same function in CollectionPage:
This extraction is justified because the behavior now has independent value and is easy to test. It was not necessary in Stage 6.
Ask the student to write one Given/When/Then test verbally for “filter by artist” before writing any test code.
Answer each in your own words first. If you can, the idea is yours.
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.
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.
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:
Safer:
The backend is not being added because React “needs a backend.” It is added when we need a trusted execution boundary.
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
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?”
Not a screen, but where the secret is allowed to live: on a trusted server, never in the browser bundle.
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 prepAnswer each out loud first, then open to check.
.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.”
“To keep credentials private, enforce our own authorization/rate limits, normalize upstream responses, and centralize retries or caching.”
“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.”
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.
Committing a real token into Git history.
Building an unrestricted proxy that accepts arbitrary upstream URLs, accidentally creating an abuse/SSRF surface.
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.
An answer key after your own attempt — never a thing to copy first.
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:
The exact hosting framework is intentionally unspecified because the security principle is the lesson.
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.
Answer each in your own words first. If you can, the idea is yours.
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.
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.
The challenge labels lyrics as a bonus. That should affect project sequencing.
A bonus API should not destabilize the core requirements:
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:
Do not couple Discogs and Genius data models.
The core app remains functional even if Genius is unavailable.
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
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.
Each track gains an optional Find lyrics link, a bonus that never blocks the core app.
Add a LyricsLink component behind the track-list UI without making it a dependency of album rendering.
Answer each out loud first, then open to check.
“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.”
“The album page should still work. Lyrics are an optional secondary integration with its own loading/error state.”
“No. I would fetch on explicit user intent, such as pressing ‘Lyrics,’ to minimize request volume.”
Automatically making a Genius request for every track as soon as album details load.
Assuming the first search result is the correct song/version.
Scraping/displaying full copyrighted lyrics without confirming an appropriate allowed/licensed mechanism.
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?”
An answer key after your own attempt — never a thing to copy first.
A safe UI boundary can start as:
src/components/LyricsLink.tsx
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.
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.
Answer each in your own words first. If you can, the idea is yours.
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.
The album page works normally; the lyrics link simply does nothing useful, or is hidden. Optional dependencies never sit on the critical path.
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:
If the student cannot explain why a library exists, the final stack is too magical.
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
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.
The finished Music Explorer: search, a responsive grid, detail pages, cached data, and tests.
An answer key after your own attempt — never a thing to copy first.
src/types.ts
src/api/discogs.ts
src/filterAlbums.ts
src/components/AlbumCard.tsx
src/components/AlbumDetail.tsx
src/pages/CollectionPage.tsx
src/pages/AlbumPage.tsx
src/App.tsx
src/main.tsx
src/index.css
vite.config.ts
Answer each in your own words first. If you can, the idea is yours.
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.
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.
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.
Progress and theme are saved only in this browser, on this device. Built for Pristone Academy.