A repeatable playbook for the frontend machine-coding interview — how to clarify, justify, plan, and build under a timer so you don't freeze when the prompt is "build YouTube in two hours."
A blank editor and "build YouTube" is paralysing only if you have no process. Run the same four phases every time, with a timer. The talking phases are short but decisive — they set the interviewer's impression before you write a line.
Requirements clarification — agree on the feature scope.
Tech stack — name your choices and why.
Planning — HLD layout + LLD component tree.
Build — skeleton first, then feature by feature, narrating.
Treat it like a road trip, not a drag race. Five minutes with the map (clarify + plan) gets you there faster than flooring it in a random direction. The interviewer is grading your navigation, not your top speed.
Open the real product (e.g. youtube.com) and narrate what it has — header, sidebar, filter buttons, video grid, the watch page, search, comments. Then propose which slice you'll actually build in the time, and confirm it.
Building features the interviewer didn't want wastes your limited time. Ask; don't guess.
The interviewer has used YouTube too — so why describe it? Because there are a thousand ways to build it, and the discussion aligns your approach with theirs. It's about shared understanding, not information.
Frame the stack around the two layers every frontend has: the UI layer (how it looks and renders) and the data layer (how state and server data flow). Decide a tool for each and say why.
| Concern | Pick | One-line justification |
|---|---|---|
| Framework | React | Component model, huge ecosystem, the role's stack. |
| Styling (UI) | Tailwind | Utility-first → build UI fast with no context-switching to CSS files. |
| State (data) | Redux | Predictable central store for a large, data-heavy app like YouTube. |
| Routing | React Router | Client-side routing for the home ↔ /watch pages. |
| Bundler | Webpack / Vite | CRA ships Webpack; Vite if starting fresh — either bundles & serves. |
| Testing | Jest + RTL | Jest runs the tests; React Testing Library queries the rendered UI. |
| Forms (if any) | Formik | Handles values, validation, errors for big forms without boilerplate. |
Context — no complex data flow. For a large app (YouTube) say Redux — predictable central store. Naming the threshold is the
point.Usually interviewers accept your picks — they're rarely rigid. Occasionally one will impose a constraint ("use plain CSS, not Tailwind") to see you adapt. Take it gracefully and move on.
create-react-app (or
Vite) and explain you could set up Webpack/Babel/Parcel by hand if needed.The stack chat is high-signal but short. Don't let it eat your build time — state your picks, give reasons, move to planning.
Sketch the home page (header, sidebar, button row, video grid) and the watch page (same
header, the video, suggestions, comments). Note the routes: / for home and
/watch for a video.
/watch?v=ID, which reads the id from the URL and embeds the player."
Stating the data flow shows you've thought past layout.Break the UI into named components and their nesting. This is the artifact you'll literally build from — each box becomes a file.
// the component tree you'll build App ├ Head // hamburger, logo, search, user icon └ Body ├ Sidebar // menu items (collapsible) └ MainContainer ├ ButtonList // filter chips └ VideoContainer └ VideoCard // one per video WatchPage // route /watch ├ Head // reused └ WatchVideo // embedded player + (suggestions, comments)
Setting up Webpack/Babel/Parcel from zero burns time you don't have. Start from a scaffold and say so: "I'll use create-react-app for speed; for a large production app I'd configure my own bundler." That shows you know the tradeoff.
// scaffold + run (know what each word means) npx create-react-app namaste-youtube // npx = execute a package once; create-react-app = the generator npm run start // check the script in package.json; CRA serves on :3000
npx runs a package once
without installing it globally; create-react-app is the generator
package. Explaining this casually reads as depth.package.json: check scripts — it's npm run start for CRA.
Don't guess.Add Tailwind (install, init config, set the content glob, add
the three directives to your CSS), confirm a test class renders, then create every component as an
empty shell and nest them per your tree.
// Tailwind in a CRA project npm i -D tailwindcss npx tailwindcss init // tailwind.config.js content: ["./src/**/*.{js,jsx}"], // files Tailwind scans // index.css @tailwind base; @tailwind components; @tailwind utilities;
content glob silently purges
your classes; and forgetting to import the CSS file means no styles apply at all. Verify with a
quick text-3xl font-bold.flex for simple rows; grid grid-cols-12 + col-span-* for a header
split into logo / search / user. Knowing both reads as fluency.Type rafce in a .jsx file to scaffold an
arrow-function component with export instantly. Fair to use under interview time pressure.
On /watch?v=ID, use useSearchParams().get("v") to get the video id, then embed the player
or fetch its data.
Wire a real data source (e.g. YouTube's API) for the feed, search, and comments — live data makes the build feel production-grade.
Treat warnings/errors as bugs. JSX uses className and camelCase DOM
props — fix "invalid DOM property" warnings rather than ignoring them.
As you build, say what you're doing and why: "I'm making Head its own component so the watch page can reuse it." That running commentary is exactly the signal interviewers reward.
/watch." It shows direction and keeps the interviewer with you.This is rehearsable. Build the whole app yourself under a two-hour timer, recording your screen and voice, explaining every step as if to an interviewer. Do the 5-min clarify and 5-min plan out loud too.
It's like a cooking show. The chef isn't just making the dish — they're explaining each step while doing it and glancing at what's next. That commentary is the performance; the finished plate alone wouldn't earn the show. Your narration is the show.
A higher-order component (HOC) is just a function that takes a component and returns a new component — usually the same one with a small modification wrapped around it. Nothing more.
// takes VideoCard, returns a bordered version of it const withAdLabel = (VideoCard) => { return (props) => ( <div className="p-5 m-2 border border-red-900"> <label>Ad</label> <VideoCard {...props} /> // pass props straight through </div> ); }; // usage: render <AdVideoCard info={...} /> const AdVideoCard = withAdLabel(VideoCard);
{...props} into it. Forgetting this is the classic "cannot destructure
property of undefined" bug.videos[0] exists — gate on it (videos[0] && ...) so the first paint doesn't crash.
An HOC is a gift-wrapping service. You hand it any product (component); it hands back the same product in fancier packaging (a border, a label, extra behavior). The thing inside is unchanged — only the wrapping is added.
Type-ahead search is the most-asked feature ("build a search bar"). The naive version fires an API call per keypress — 7 letters, 7 calls. Debouncing waits for a pause in typing before calling, collapsing 7 calls into 2–3.
// in Head: state bound to the input const [query, setQuery] = useState(""); useEffect(() => { const timer = setTimeout(() => getSuggestions(query), 200); return () => clearTimeout(timer); // cleanup cancels the pending call }, [query]); const getSuggestions = async () => { const data = await fetch(YOUTUBE_SEARCH_API + query); const json = await data.json(); setSuggestions(json[1]); // suggestions live at index 1 };
clearTimeout) and
then starts a fresh timer. If the next key lands within 200ms, the old timer is cancelled before
it fires. Only a real pause lets a timer survive to call the API.useEffect +
its cleanup gives you debouncing almost for free. Say that — it shows you understand the
lifecycle, not just the snippet.Debouncing is an elevator door. Each new person stepping in resets the close timer; the doors only shut once there's a gap with no one entering. Fast typing keeps resetting the timer; a pause lets it finally "close" and fire the call.
The next level: don't re-fetch a query you've already searched. Cache results in the store, keyed by the query string, and read the cache before fetching. The data structure you choose is the whole point.
// search slice: cache is an OBJECT keyed by query, not an array const searchSlice = createSlice({ name: "search", initialState: {}, reducers: { cacheResults: (state, action) => { state = Object.assign(state, action.payload); // merge {query: results} }, }, }); // in Head, before fetching: if (searchCache[query]) setSuggestions(searchCache[query]); // O(1) hit else { /* fetch, then */ dispatch(cacheResults({ [query]: json[1] })); }
includes/indexOf scan one by one). Keying an
object (a hash map) by the query string makes lookup O(1). This is the data-structures answer
interviewers fish for — "DS matters even on the frontend.""Build a comment section that nests to any depth" (think Reddit) is a favourite because it needs UI and data-structure and recursion. The trick is modelling the data as a tree, then rendering a component that calls itself.
// each comment can contain replies that are themselves comments → a tree const data = [{ name, text, replies: [ {name, text, replies:[...] } ] }]; const CommentsList = ({ comments }) => comments.map((c, i) => ( <Comment key={i} data={c} /> )); const Comment = ({ data }) => { const { name, text, replies } = data; return ( <> <div>{name}: {text}</div> <div className="pl-5 border-l ml-5"> <CommentsList comments={replies} /> // ← recursion </div> </> ); };
{ name, text, replies: [] }
and replies is an array of the same shape. Get the recursive
data structure right and the UI falls out of it.CommentsList renders each
Comment, and each Comment renders a CommentsList for its replies. That mutual call is what supports unlimited
depth without breaking.
pl-5 border-l ml-5 on the replies
wrapper gives the Reddit-style nesting line for free.Nested comments are Russian nesting dolls. Each doll (comment) can contain a smaller doll of the exact same kind (a reply), all the way down. One "open this doll" routine (the component) handles every level — it just calls itself on whatever's inside.
For data that updates continuously there are two real options. The choice is driven by one question: how real-time does it need to be?
| Transport | Shape | Use when |
|---|---|---|
| WebSocket | Bi-directional, persistent — one handshake, then either side pushes anytime, no fixed interval. | Near real-time & order matters: chat (WhatsApp), trading / stock tickers, multiplayer. |
| API polling | One-directional — the UI requests on a fixed interval; data flows server→client. | "Eventually fresh" is fine: Gmail inbox, cricket commentary, YouTube live chat & comments. |
get_live_chat calls. "API calls are
expensive" is a myth at the right interval; the real cost is the UI, not the requests.A WebSocket is a phone call — dial once, then both sides talk freely until you hang up. Polling is checking the mailbox — you walk out every N minutes to see if anything arrived. Use the call for a live conversation; the mailbox for "did anything come in?"
Implement polling with setInterval inside a useEffect, fetch each tick, and dispatch the new messages into Redux. The
component subscribes to the store and re-renders itself — you never touch the DOM directly.
// LiveChat: poll on an interval, dispatch into the store const dispatch = useDispatch(); useEffect(() => { const id = setInterval(() => { // real version: fetch(LIVE_CHAT_API).then(r => r.json()) dispatch(addMessage({ name: getRandomName(), message: getRandomMsg() })); }, 1500); return () => clearInterval(id); // cleanup: stop polling on unmount }, []); // run once after mount
clearInterval(id) is
mandatory — without it, leaving the page keeps firing calls forever (a real memory/network
leak). Same rule for setTimeout.fetch → .json() → dispatch(addMessage(...)). Build with stub data first to prove the loop,
then wire the API.console.log inside the
interval first to confirm it ticks, then add the dispatch. Step-by-step beats "write it all,
then debug."This is the part most candidates miss. If you keep appending message divs forever, the DOM grows without bound and the page eventually freezes. The fix — the one YouTube actually uses — is to keep only the latest N messages and drop the oldest.
// chatSlice reducer: add newest, evict oldest past the cap addMessage: (state, action) => { state.messages.splice(LIVE_CHAT_LIMIT, 1); // drop one past the limit state.messages.unshift(action.payload); // newest goes to the front } // LIVE_CHAT_LIMIT lives in constants → tune per device/browser
splice(LIMIT, 1) + unshift: every time
you add a message at the front, you remove one at the cap — so the list size stays fixed
no matter how long the page is open. Leave it running ten hours and it won't crash.LIVE_CHAT_LIMIT
in your constants file so you can tune it — a beefy desktop on the latest Chrome can hold
more than an old mobile. Configuring per-device is the senior flourish.flex-col-reverse + overflow-y-scroll so newest sits at the bottom and the column scrolls
like a real chat.key, never the array index, since the list constantly mutates.The capped list is a conveyor belt with a fixed number of slots. Each new box that rolls on pushes the oldest box off the far end. The belt never overflows the room — it just keeps the most recent items moving, exactly like a live chat window.
useState and useEffect 99% of the time.
The performance hooks come up when an interviewer probes optimization — and the points go to
whoever can explain why a component re-renders and what that costs, not
just recite an API. The foundation: every render re-runs the component function from scratch, so
every plain variable is recreated.A component is just a function. When state or props change, React calls that function again — a fresh execution context, fresh memory, all local variables recreated. That single fact explains every hook below.
// a plain `let` is wiped on every re-render let x = 0; // recreated to 0 each render const [y, setY] = useState(0); // React persists this across renders // clicking "x++" mutates x — but no re-render, so the UI never updates, // and the next state-driven re-render resets x back to 0.
let doesn't work as UI state: incrementing it changes the
value in memory, but React doesn't re-render, so the screen never updates — and the moment
any other state change triggers a render, the variable is reinitialized and your value
is lost.useState works: React stores it outside the function's
per-render memory and re-renders when you call the setter. That's the entire reason state hooks
exist — a question many React devs can't answer in depth.console.log fires twice, that's why — don't "fix" it by removing
StrictMode in real code.Each render is a fresh whiteboard. Plain variables are written on it and wiped clean every time you redraw. State and refs are written in a notebook React keeps off to the side — they survive each wipe.
If a render does heavy work (say, computing the Nth prime), that work re-runs on
every render — even one triggered by an unrelated state change like toggling a theme.
useMemo caches the result and only recomputes when its dependencies change.
// without memo: findPrime runs on EVERY render — theme toggle freezes the page const prime = findPrime(number); // with memo: cached between renders, recomputed only when `number` changes const prime = useMemo(() => findPrime(number), [number]); // toggling the theme now re-renders instantly — the prime isn't recomputed
[number] means
"recompute only when number changes." On any other re-render, return the cached value. Get the
deps wrong and you either over-compute or serve stale results.useMemo adds
overhead for no gain. Reach for it only when you've identified a genuinely expensive operation
— premature memoization is a real anti-pattern.useMemo just skips redoing the expensive work inside it.Nearly identical to useMemo, but it caches a function
instead of a computed value. Because every render recreates inline functions, a memoized callback
keeps the same reference across renders — useful for skipping re-renders of memoized children.
// useMemo caches a VALUE; useCallback caches a FUNCTION reference
const handleClick = useCallback(() => doThing(id), [id]);
useMemo → "cache the result of a
calculation"; useCallback → "cache the function definition
itself." Same dependency-array mechanics.Sometimes you want a value that survives re-renders (unlike a let) but doesn't trigger a re-render when it changes (unlike state).
That's exactly useRef — it returns an object { current } you mutate directly.
// useRef returns an object, not a bare value: { current: initial } const ref = useRef(0); ref.current = ref.current + 1; // mutate directly — no setter, no re-render // classic use: hold a timer id so a button can clear it later const timerId = useRef(null); useEffect(() => { timerId.current = setInterval(tick, 1000); return () => clearInterval(timerId.current); // cleanup on unmount }, []); // a "Stop" button elsewhere can call clearInterval(timerId.current)
let is
recreated every render; useState persists and re-renders on
change; useRef persists but does not re-render. Knowing all
three cold is what interviewers fish for..current: useRef(0)
gives you { current: 0 }, not 0. You read and
mutate ref.current directly — there's no setter.let fails for a timer id: React warns that a value
assigned to a plain variable inside a hook is lost after each render. A ref is the correct home
for things like interval ids you need across renders but don't render.useRef is rarely needed in everyday app code
— say so. But being able to explain why it exists (persist without rendering) is
a strong signal.A ref is a sticky note on the monitor. You can scribble on it and change it anytime, and it stays put when the screen redraws — but changing it doesn't cause the screen to redraw. State is a note that also rings a bell to refresh the screen; a ref is a silent note.
/watch). LLD is the component tree — the named
components and their nesting. HLD decides the screens; LLD decides the files you'll create.
npx create-react-app
actually do?npx executes an npm package a single time without a global
install; create-react-app is the generator package that scaffolds a new
React project (folder structure, bundler, scripts, testing). Explaining this casually signals
real understanding./watch?v=ID. On the watch page, read
the id with useSearchParams().get("v"), then embed the player (an
iframe) or call the video API with that id. Keeping it driven by the URL makes it shareable and
dynamic.useEffect
on that state start a setTimeout (e.g. 200ms) to call the API —
with a cleanup that clearTimeouts the previous timer. Fast typing keeps
cancelling the pending timer; only a pause lets a call actually fire, collapsing many calls into
a few.useEffect cleanup make
debouncing work?cache[query]: that lookup is
O(1), versus O(n) scanning an array with includes/indexOf. On a hit, set suggestions from cache and skip the call entirely.
Mention bounding it with an LRU/FIFO cap for long sessions.{ name, text, replies: [] } where replies holds
more comments of the same shape. Then render recursively: a list component renders each comment,
and each comment renders the list component again for its replies. The self-call supports
unlimited depth. Use real ids as keys, not the index.get_live_chat calls roughly every 1.5 seconds. Chat doesn't need strict
ordering or millisecond latency, so polling is good enough and cheaper than holding millions of
open sockets. WhatsApp, by contrast, needs WebSockets because message order is critical.useEffect with an empty dependency array, start a
setInterval that fetches and dispatches the data into your store, and
return a cleanup that calls clearInterval. The empty array runs it once
after mount; the cleanup stops polling on unmount so you don't leak calls.
splice(LIMIT, 1) then unshift(newItem). The list size stays fixed however long the page is
open, so the DOM never bloats. This is what YouTube actually does (~250 messages).useState setter) or a prop change.
React then re-runs the component function from top to bottom — a fresh execution context
with all local variables recreated. Understanding this is the basis for every performance hook.
useState instead of a plain
let variable?
let changes in memory but doesn't trigger a re-render,
so the UI never updates — and it's reset to its initial value on the next render since the
function re-runs. useState is persisted by React outside that
per-render memory and re-renders the component when its setter is called.useMemo do, and when do you
use it?useMemo and useCallback?useMemo caches a computed value; useCallback caches a function reference. The mechanics
(dependency array) are identical. A stable function reference is useful when passing callbacks
to memoized child components so they can actually skip re-rendering.let is recreated on every render (doesn't persist,
doesn't re-render). useState persists across renders and
re-renders the component when changed. useRef persists across renders
but does not trigger a re-render — ideal for values like timer ids you need to
keep but don't display.useRef an object with .current?useRef(initial) returns { current: initial } rather than a bare value, so React can hand you the
same mutable container across every render. You read and write ref.current directly — there's no setter and mutating it doesn't
re-render.