Interview Prep · Senior Frontend · Technical Round 1
Senior Frontend Interview — Round 1
The screening technical round: a strong self-intro, how you break a requirement into work, then the
JavaScript, TypeScript, React, and design-pattern questions that get asked over and over — plus the small
live-build tasks (typed localStorage, a permissions helper, a useDebounce that
actually works) they use to check you can actually code.
this,
or a stale useEffect), and you can write small correct code under mild pressure.
Seniority shows up not in trivia but in how you talk about trade-offs, failure modes, and
why — always give the answer, then one sentence of "here's when it bites."
Your intro & work experience
The first 3–4 minutes set the tone. A rambling "tell me about yourself" makes the interviewer work to find your signal; a tight one makes them relax and trust you. Have this memorised as a shape, not a script — you want it to sound spoken, not recited.
"Tell me about yourself" — the shape
Use Present → Past → Why-here. Keep it to ~60–90 seconds. Lead with what you do now and the scale/impact, pull one or two threads from your past that explain how you got good, and land on why this role.
"Walk me through a project" — use STAR, but lead with the hard part
Senior signal is ownership and judgment, not feature lists. Frame the project as a problem you diagnosed and a decision you made, using Situation → Task → Action → Result — but front-load the interesting technical tension so they lean in.
Feature tour
"BestWe has surveys, dashboards, team-health metrics, a feedback view, a profile page…" — a list of screens. No decision, no you.
Decision story
"Genius needed to work in four different surfaces with different UI but identical behaviour. Instead of four implementations I built one shared SSE custom hook plus reusable core/wrapper components — one source of truth for the logic, consistent streaming UX everywhere."
Have 2–3 stories ready that each showcase a different muscle. You have strong ones for each — the full write-ups are in §1.4:
- An architecture / technical-decision story: the active-round scoping decision (§1.4) — choosing a session-scoped Context + localStorage over global Redux, picking the smallest scope that satisfies the requirement — or the Genius four-surfaces reuse above.
- A debugging / firefight story: the stream executor race conditions (§1.4) — a stale watchdog killing the wrong stream, abort-vs-timeout ambiguity, a retry reconnecting a superseded stream. Walk the method: reproduce → isolate → fix → guard so it can't recur.
- A systems / leadership story: Integrated Sessions (§1.4) — a branching, multi-mode session runtime — or setting a standard, e.g. getting the surfaces consuming Genius onto the shared hook instead of forking it. Senior roles are graded on this.
Your signature story — Genius (ready to read, ~2 min)
This is your strongest project story — memorise the shape, not the words. Lead with the tension (same feature, four surfaces), then the design (shared SSE hook + core/wrapper split), then the payoff (reuse + consistent UX). Pause between paragraphs; it's ~2 minutes spoken slowly.
"One of the more interesting features I worked on recently was Genius, our AI assistant, which we rolled out across multiple parts of the app.
The key challenge was that it wasn't a single feature — the same AI interaction had to work in four different places, each with different UI and slightly different state handling, but the behaviour had to stay consistent.
Instead of building four separate implementations, I designed a shared streaming layer over Server-Sent Events. I created a custom hook that owned the whole lifecycle — starting the request, forming the payload, managing the stream connection, handling incremental responses, and routing the data to the right reducer based on the active context.
On top of that I separated the rendering from the data layer: the core logic stayed shared, and each surface used its own wrapper components for layout. That kept the UX consistent — how messages stream in, how typing states appear, how actions are handled — while still allowing flexibility per screen.
I also handled the messy edge cases — retries, partial streams, and making sure that if a user navigates away mid-response and comes back, the stream state stays consistent.
Overall it let us reuse the same underlying logic across four consumers, cut duplication, and made the AI features much easier to maintain and extend."
More stories to pull from — deep-dive backups
Three fuller stories, each flexing a different senior muscle: a scope decision, a concurrency firefight, and a subsystem architecture. Don't memorise them — hold the tension, the decision, and the one-line lesson (the green line at the end of each), and be ready to go as deep as they push. Expand as needed.
A.Scoping the active round to a session — the time I didn't reach for Redux
The tension. The "active round" — a time-boxed cycle everything keys off — already lived in global
Redux state (backed by localStorage, read everywhere via helpers). A new requirement: inside a
session, let a user peek at a previous round's data and come back — without changing the round
for other users, or even for the rest of their own app. A temporary, local lens.
The instinct I distrusted. The default here is Redux, so I sketched a viewingPreviousRound
flag + temporaryRoundId in a reducer. The moment I wrote the cleanup dispatch, I got
uncomfortable: global state you must remember to tear down is a leak. If the router yanks the user away
(deep link, back button) the override never clears — now the whole app thinks they're in the previous
round. Every failure mode existed only because the state was global, for something whose whole point
was to be narrow.
The decision. I flipped it: the temporary round lives in a React Context scoped to the session
subtree, with localStorage as the only durable backing. The override's lifetime = the
provider's lifetime — unmount the session and it's gone automatically. The scope is the cleanup.
Consumers get a naive API (useActiveRound() / useSetActiveRoundId()) and never know
they're on a real round or a temporary lens. I left the reasoning in the file so no one "helpfully" promotes
it back to Redux.
/**
* Stores activeRoundId only within this Context and localStorage (not a global
* reducer), as global state is not needed here. Enables temporary switching to a
* previous round locally within a session without affecting global app state or
* switching rounds for all users.
*/
The lesson: "Should this be global?" is one of the most consequential questions in a frontend — pick the smallest scope that satisfies the requirement, not the most familiar one. Context + localStorage removed a whole class of bugs by construction instead of guarding against them one dispatch at a time.
If they dig in: how an effect resolves the real previous-round id when
usingPreviousRound flips on and snaps back to initialRoundId when it flips off; why
localStorage is still needed (durability across reload); the Context re-render trade-off.
B.The stream executor — turning a race-condition minefield into one small state machine
The tension. Genius streams AI over SSE. It started simple (open → pipe chunks into Redux → close), then reality piled on: multiple background streams per slide (depth levels), ad-hoc user questions that must jump the line, retries with backoff (but not for interruptions we caused), and navigation while all of that is in flight. The naive "every component opens its own stream" produced overlapping connections into the same Redux buckets, retries firing against superseded streams, dead watchdogs killing live streams, and a "Regenerate" button that appeared or not depending on callback order — emergent bugs, not one-at-a-time ones.
The decision. One owner. A singleton service is the only thing allowed to open an SSE connection; everything else enqueues intent and reacts to Redux. It holds a queue and exactly one active entry — that "one at a time" constraint turns an N-way race into a sequence of well-defined transitions. The queue is a priority queue with preemption: an ad-hoc question aborts the active background stream, pushes it back to the front to replay later, and runs now; duplicate background streams dedupe; after an ad-hoc, background prefetch for that slide is paused until the user navigates away.
Where the time actually went — every place asynchrony let two truths coexist:
- The stale watchdog (aborts a stalled connection) kept ticking after preemption and fired against the
next stream. Fix: make it per-invocation — each attempt owns its interval, cleared in a
finallyon every exit path. A stream can't reach out of its grave and kill its successor. - Abort-vs-timeout ambiguity: the SSE client resolves (doesn't reject) on abort, so an
intentional preemption and a real timeout looked identical — and the shared
abortControllermay already have been replaced. Fix: a localisWatchdogTimeoutflag captured in the closure. Local truth beats shared truth when everything's moving. - A retry reconnecting the wrong stream: if an ad-hoc preempted during the backoff window, a naive
null-check would reconnect and run two streams at once. Fix: capture the
streamIdat schedule time and bail if it no longer matches — identity, not presence. - The missing Regenerate button: the shared field could be nulled by a newer stream before the catch ran. Fix: store the failed stream from the closure params, never the shared field another stream can stomp.
The lesson: in concurrent code, prefer values captured in local scope over fields read off shared mutable state — between the write and the read, the world moved. Read live from the store when you need current truth; hold captured closures when you need the truth as of schedule time — knowing which you're in is the whole game.
If they dig in: why a singleton over per-component streams; how preemption replays a pushed-back entry; why SSE resolves-on-abort matters; how depth-level buckets nest so preempting an in-depth stream leaves the core answer's cache intact.
C.Integrated Sessions — a guided, branching, multi-modal experience engine
What it is. From the outside, a slideshow with a chatbot beside it. Underneath, the most involved subsystem I've built here: a content-driven, branching, multi-mode session runtime. Sessions are authored in Notion, rendered as slides, narrated by Genius, and the user walks a path that branches on their choices and learning level.
The design tensions I resolved.
- Untrusted external content. Slides come from the Notion API — rich-text arrays, per-learning-level
columns, flags inferred from an icons column that might be
multi_selectorrelation. A defensive translation layer turns that into a stable internalSlideshape. The mess stops at the boundary — downstream never sees a raw Notion row. - One session, four modes (Scan / Chat / Read / Listen): four projections of one state tree with mode as a parameter, not four forked components that drift apart.
- Navigation is a graph, not a line. One option ⇒ auto-advance; many ⇒ a branch the user chooses. That meant real graph traversal — walk single-option chains and stop at branches, reconstruct the jumped path with cycle guards (authored content will eventually loop), and clear now-invalid downstream state when a user diverges from a previously-walked path.
The bottleneck. Narrating lazily would stall every slide transition on a cold AI call; naive prefetch would fan out exponentially down branches and hammer the backend. Resolution: prefetch only along deterministic single-option chains, stop at branches, hard-cap the blast radius, and dedupe in-flight requests. Crucially, the prefetcher doesn't open connections itself — it hands intent to the stream executor (story B). The two were co-designed: the prefetcher can be aggressive precisely because the executor preempts cleanly when the user interrupts with a real question.
The lesson: containment. Each source of complexity is walled into one place — Notion messiness into the translation layer, streaming into the executor, path logic into the traversal helpers. No single file holds the whole beast, which is the only reason it stays maintainable.
Behavioural questions that sneak into a tech round
Q.What's the hardest bug you've debugged?
Q.Tell me about a technical decision you disagreed with.
Q.Why are you leaving / looking?
Q.What does "senior" mean to you?
"How do you break a requirement down and split it across the team?"
This is the question you froze on. It's not really about project management — they're checking whether a vague sentence from a manager turns into a plan with edge cases in your head, or into code you rewrite three times. You don't need to have led a team to answer it. You need a process, and one honest example of using it on a feature.
The six steps, with what you actually do in each
1.Clarify the why — ambiguity is cheapest today
The requirement has been through a game of telephone: client → manager → you. So the first thing I do is restate it back as an outcome and check I've got it: "So a team lead wants to compare this round with the previous one, without losing where they are — is that right?" That one sentence catches most misunderstandings for free.
Then three questions I try to always ask, because the answers change the design:
- Who is this for, and what are they doing when they need it? A feature for an admin once a quarter and a feature for every user daily are different builds.
- What does "done" look like from the client's side? The acceptance criteria, in their words — this becomes my definition of done later.
- What's not in this? Explicitly naming what we're not building now is the cheapest scope control there is.
Question I ask when the requirement feels thin: "what happens today, and why is that painful?" The pain tells you what actually matters when you have to cut something.
2.Map the flow, not the features
I don't start by listing components. I walk the user's path out loud, step by step: they land here → they click this → they see this state → they pick an option → this changes. Each step in that walk is a candidate task, and — more usefully — each arrow between steps is where the edge cases live (what if it's slow, what if it's empty, what if they go back).
Writing the flow down is also how I catch requirements nobody stated. "They pick a previous round" quietly implies: there's a list of rounds, it has an order, it can be empty, and one of them is the current one. None of that was in the requirement, all of it is work.
3.Slice it vertically — thin, shippable, demoable
The instinct is to split horizontally — "one person does the API layer, one does the components, one does the state." It feels tidy and it's a trap: nothing works until everything works, and you find out on the last day that the pieces don't fit.
Instead I slice vertically: each slice is a thin end-to-end path that you can actually demo. Slice 1 might be "read-only, one hardcoded round, no switching, but it renders real data." That's ugly and useful — it proves the data shape, the contract, and the layout in one go, and everything after it is an increment on something real.
Split by layer
"You do the API, you do the UI, you do the state." Integrates once, at the end, badly. Everyone is blocked on everyone.
Split by user-visible slice
"Slice 1: it renders one round read-only. Slice 2: you can switch. Slice 3: it survives a refresh." Each one ships and each one teaches you something before the next.
My rule of thumb for size: if a task can't be described in one sentence with a clear "done," it's still two tasks.
4.Hunt edge cases with a checklist, not with inspiration
This is the part interviewers are most listening for, and the trick is that it isn't creativity — it's a checklist you run every time. I go through the flow I mapped in step 2 and ask the same questions at each step:
- States: loading, empty, error, partial, success. Every screen has all five whether you designed them or not — undesigned ones just look like bugs.
- Counts: zero, one, many, absurdly many. Zero rounds, one round, 400 rounds — does the UI still make sense? Do I need pagination or virtualisation?
- Boundaries & bad data: a very long name, a missing field, a null the API "never" sends, a different timezone or locale.
- Permissions: who can see this, who can't, and what does the "can't" case look like? (And the client check is a UX affordance — the server still has to enforce it.)
- Timing: slow network, the request that resolves after the user moved on, a double-click, two things in flight at once. This is where the real bugs are.
- Navigation: refresh, back button, deep link into the middle of the flow, open in two tabs.
- Reach: mobile width, keyboard-only, screen reader, translated text that's 40% longer.
I write the answers into the ticket as acceptance criteria. The important half is the deliberate deferrals: "400 rounds isn't handled — we'll paginate when a client passes 50." Saying that out loud turns a future bug report into a known, priced decision.
"Undesigned states are the same as broken states" — I'd rather agree on an ugly empty state now than ship a blank screen.
5.Assign by shape of the work, not by size of the person
Once it's sliced, delegation is mostly matching the shape of a task to who should hold it:
- The uncertain/risky slice goes first, and to whoever can absorb the surprise — often me. If the streaming layer or the tricky state model is going to change the plan, I want it changed in week one, not week three.
- Well-specified slices go to whoever's newer, with the acceptance criteria and an example to copy from. A junior with a crisp ticket outperforms a senior with a vague one.
- Define the seams first so people can work in parallel. Agree the types/props/contract up front — once the interface is fixed, two people can build on either side of it without stepping on each other, and nobody is blocked waiting for the API to be real.
- Nobody owns half a slice. One person owns a slice end to end; that way "done" is unambiguous and there's no seam where responsibility falls through.
6.Close the loop — early and in small pieces
The requirement came through two people, so my assumptions will be wrong somewhere. I send the breakdown back to my manager as flows and open questions before we start — "here's what I understood, here's what I'm assuming, here's what I need answered" — because a wrong assumption caught in a message costs minutes and the same one caught in review costs a week.
After that, small demos beat status updates. Showing slice 1 gets you a real correction from the client; "60% done" gets you nothing.
What to actually say (~90 seconds, honest version)
"I'll be upfront that I've done this mostly at feature scale rather than running a whole team's roadmap — but the process I follow is the same either way.
When a requirement comes down from a manager, it's usually an outcome, not a spec — and it's been through the client and the manager before it reached me. So the first thing I do is restate it back as a user outcome and check I've got it right, and ask what 'done' looks like from the client's side. That one conversation catches most of the misunderstandings.
Then I map the user flow step by step instead of listing features, because the gaps show up between the steps. From that flow I slice the work vertically — thin end-to-end pieces you can actually demo — rather than splitting it by layer, where nothing works until everything works.
Edge cases I handle with a checklist rather than inspiration: for every step, what does loading, empty, and error look like; zero, one, and a thousand items; who's allowed to see it; what happens on a slow network, a refresh, or the back button. I put the answers in the ticket as acceptance criteria — and I write down the ones we're deliberately not doing, so they're a decision instead of a surprise.
For splitting it up, I try to take the uncertain piece myself so the risk lands early, give the well-specified slices to whoever's newer with clear acceptance criteria, and agree the interfaces up front so people can work in parallel without blocking each other.
Then I send the breakdown and my open questions back up before we start — the assumptions are cheapest to fix when they're still in a message."
JavaScript — the fundamentals they always probe
These are the "prove you actually understand the language" questions. Answer with the definition first, then a one-line gotcha. If you can, reach for a tiny example.
Q.What is a closure? Give a real use.
for (var i…) setTimeout bug.Q.Hoisting, the TDZ, and var vs let vs const?
var is
function-scoped and initialised to undefined (so reading it early gives undefined).
let/const are block-scoped and hoisted too, but live in the Temporal Dead Zone
until the line that declares them — reading early throws ReferenceError. const can't
be reassigned (but the object it points to is still mutable). Function declarations are fully hoisted;
function expressions are not.Q.How does this work?
this is set by how it's called: method call →
the object; plain call → undefined (strict) or global; new → the new instance;
call/apply/bind → whatever you pass. Arrow functions have no own this — they
capture it lexically from where they're defined, which is why they're great for callbacks and useless as object
methods.Q.Explain the event loop — micro vs macro tasks.
queueMicrotask, MutationObserver)
completely, then takes one macrotask (setTimeout, I/O, UI events), then drains
microtasks again, and repeats. Key consequence: a resolved Promise's .then runs before a
setTimeout(0). Long synchronous work or an endless microtask chain starves rendering.console.log('A');
setTimeout(() => console.log('B'), 0); // macrotask
Promise.resolve().then(() => console.log('C')); // microtask
console.log('D');
// Output: A D C B (sync → microtasks → macrotasks)
Q.Promises vs async/await; and == vs ===?
async/await is syntax sugar over Promises — await pauses the async
function until the Promise settles, and try/catch replaces .catch. Use
Promise.all for concurrency, allSettled when failures are OK, race for
timeouts. === compares without type coercion; == coerces (0 == '',
null == undefined are true) — always use === except the deliberate
x == null to catch both null and undefined.Q.Prototypal inheritance?
[[Prototype]]) to another object. Property lookup
walks this prototype chain until found or it hits null. class is sugar over
this — methods live on Prototype.prototype and are shared across instances (not copied). This is
why adding a method to a prototype affects all existing instances.Q.Shallow vs deep copy? How do you deep clone?
{...o}) and Object.assign copy one level — nested objects are
still shared references. For a deep clone use structuredClone(obj) (built-in, handles
Dates/Maps/Sets/cycles). JSON.parse(JSON.stringify(x)) works but drops functions,
undefined, Date→string, and throws on cycles.Q.Debounce vs throttle — and can you write one?
function debounce(fn, wait = 300) {
let t;
return function (...args) {
clearTimeout(t);
t = setTimeout(() => fn.apply(this, args), wait);
};
}
function throttle(fn, wait = 300) {
let last = 0, timer;
return function (...args) {
const now = Date.now();
const remaining = wait - (now - last);
if (remaining <= 0) { // leading edge
clearTimeout(timer); timer = null;
last = now;
fn.apply(this, args);
} else if (!timer) { // trailing edge
timer = setTimeout(() => {
last = Date.now(); timer = null;
fn.apply(this, args);
}, remaining);
}
};
}
Q.Event bubbling, capturing, and delegation?
addEventListener is bubble-phase by default; pass {capture:true} for capture.
Delegation = put one listener on a parent and use event.target to handle many children —
fewer listeners, and it works for elements added later. stopPropagation halts travel;
preventDefault cancels the default action (they're different).Q.What is currying? Memoization?
f(a,b,c) into f(a)(b)(c) — useful for partial
application and building configured helpers. Memoization caches results by arguments so repeated calls
are free — great for pure, expensive functions.const memoize = (fn) => {
const cache = new Map();
return (...args) => {
const key = JSON.stringify(args);
if (cache.has(key)) return cache.get(key);
const result = fn(...args);
cache.set(key, result);
return result;
};
};
Q.ESM vs CommonJS?
import/export) is static — analysable at build time, enables tree-shaking,
and is async-loadable. CommonJS (require/module.exports) is dynamic and synchronous, the old
Node default. ESM bindings are live read-only views of the export; CJS gives you a copy of the value at require
time.for (var i = 0; i < 3; i++) setTimeout(() => console.log(i))
logs 3 3 3 — one shared var captured by all closures. Fix: use let
(per-iteration binding) → 0 1 2.TypeScript — the questions that separate users from understanders
At senior level they expect you to reach past any: generics, narrowing, utility types,
and knowing when the type system is lying to you. Answer with the concept, then when you'd use it.
Q.interface vs type?
interface supports declaration
merging and reads well for public API/object contracts, and can extends. type
can express unions, intersections, tuples, mapped and conditional types — anything, not just objects. Rule of
thumb: interface for object shapes you might extend; type for unions and computed
types.Q.any vs unknown vs never?
any switches off type checking — avoid. unknown is the safe
top type: you can hold anything but must narrow before using it. never is the bottom
type — no value is assignable; it's the return of a function that always throws, and the tool for
exhaustiveness checks in a switch.Q.What is structural typing?
Q.Explain generics and a constraint.
extends to require capabilities. Example: a typed "get property" that TS proves is safe.function get<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { id: 1, name: 'Ada' };
get(user, 'name'); // string ✅
get(user, 'email'); // ❌ compile error — 'email' is not keyof user
Q.Which utility types do you use?
Partial<T> (all optional), Required<T>,
Readonly<T>, Pick<T,K>/Omit<T,K> (subset a type),
Record<K,V> (dictionary), ReturnType<F>/Parameters<F>
(extract from a function), Awaited<T> (unwrap a Promise), and
NonNullable<T>. They keep types derived from one source of truth instead of duplicated.Q.Type narrowing & discriminated unions?
typeof,
instanceof, in, truthiness, or a custom type guard x is Foo). A
discriminated union gives each variant a literal "tag" field so a switch on the tag narrows
exhaustively — the cleanest way to model state like loading/success/error.type Result<T> =
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
function render<T>(r: Result<T>) {
switch (r.status) {
case 'loading': return 'Loading…';
case 'success': return r.data; // TS knows .data exists here
case 'error': return r.error.message;
default: {
const _exhaustive: never = r; // compile error if a case is added and unhandled
return _exhaustive;
}
}
}
Q.What is as const, and satisfies?
as const freezes a literal to its narrowest readonly type (so
'GET' is the literal 'GET', not string) — great for config objects and
action types. satisfies checks a value against a type without widening it, so you
get validation and keep the precise inferred type. Use satisfies over a type annotation when you
want both safety and the narrow literal types.Q.Enums vs union of string literals?
type X = 'a' | 'b' (or an as const object) in most frontend code:
it's zero-runtime, tree-shakeable, and plays well with JSON. enum generates runtime code and has
quirks (numeric enums are bidirectional; const enum has its own caveats). Reach for enums mainly
when you need a named runtime value.localStorage, URL params) still needs runtime validation (a type guard or a library like Zod).
A cast (as) is a promise to the compiler, not a check.React — hooks, rendering, and performance
Round-1 React is mostly about the render model and hooks correctness. If you can explain why a component re-renders and how to stop the unnecessary ones, you sound senior.
Q.How does reconciliation work, and why do keys matter?
Q.What triggers a re-render?
state/useReducer updates, its parent
re-renders (so children re-render by default), or a context value it consumes changes. Props changing
is really just "the parent re-rendered." Re-rendering ≠ touching the DOM — React still diffs; it's the wasted
render work you optimise away with memo/useMemo/useCallback.Q.The rules of hooks — and why?
Q.useEffect dependencies and cleanup?
AbortController for fetches to avoid setting state after unmount. In dev, StrictMode
mounts→unmounts→mounts to surface missing cleanup — that double-run is intentional.useEffect(() => {
const ctrl = new AbortController();
fetch(`/api/user/${id}`, { signal: ctrl.signal })
.then(r => r.json())
.then(setUser)
.catch(err => { if (err.name !== 'AbortError') setError(err); });
return () => ctrl.abort(); // cancel on id change / unmount
}, [id]);
Q.useMemo vs useCallback vs React.memo — and when NOT to use them?
useMemo caches a computed value; useCallback caches a
function identity; React.memo skips a child's re-render if its props are shallowly equal.
They matter together: memoising a callback is pointless unless the child is memo'd. Don't
reach for them by default — they cost memory and comparisons, and premature memoisation clutters code.
Add them when you've measured a real re-render cost or need stable identity for a dependency array.Q.useState vs useReducer? useRef?
useReducer when the next state depends on the previous in non-trivial ways, when
several values change together, or when logic is worth testing in isolation. useRef holds a mutable
value that persists across renders without causing one — for DOM nodes, timers, previous values, or "has
this run" flags. Changing ref.current does not re-render.Q.Controlled vs uncontrolled components?
value + onChange)
— predictable, validate-as-you-type, easy to reset. Uncontrolled: the DOM holds the value, you read it via
a ref (or on submit) — less code, fewer renders, good for simple/large forms and file inputs. Senior answer:
controlled for anything interactive or validated; uncontrolled when you just need the final value.Q.Why can Context hurt performance, and what do you do?
Q.useEffect vs useLayoutEffect?
useEffect runs after the browser paints (async, non-blocking) — the default.
useLayoutEffect runs synchronously after DOM mutation, before paint — use it only to measure
layout or mutate the DOM to avoid a visible flicker, since it blocks painting.Q.What are error boundaries, portals, and forwardRef?
componentDidCatch/
getDerivedStateFromError) catch render-time errors in their subtree and show a fallback — they don't
catch async/event-handler errors. Portals render children into a different DOM node (modals, tooltips)
while keeping React tree context and event bubbling. forwardRef lets a parent pass a ref
through to a child's DOM node or imperative handle.Q.How do you find and fix a slow React app?
memo hot
children), virtualise long lists (react-window), code-split with
React.lazy/Suspense and route-based chunks, defer non-urgent updates with
useTransition/useDeferredValue, and debounce expensive derived work. Always tie a fix
to a measured cause.Q.Server Components / SSR / SSG in one breath?
Q.How do you stream AI/model responses in React — SSE vs WebSocket vs fetch streams?
EventSource, or a fetch + ReadableStream) is the right default
for one-directional, server→client token streaming: it's plain HTTP, auto-reconnects, and is simple to consume.
Reach for WebSockets only when you need true bidirectional/real-time (chat presence, collaborative
editing). On the client, keep the transport in a custom hook that owns the request, the stream lifecycle,
and routing chunks into state — then batch incremental updates and clean up on unmount/navigation with an
AbortController. Handle the edge cases out loud: partial streams, retries with backoff, and
resuming/cancelling if the user leaves mid-response. (This is exactly the Genius design in §1.3 — be ready to
defend SSE over WebSockets and to explain where the shared logic ends and per-surface UI begins.)setCount(c => c + 1), not setCount(count + 1) — the latter
captures a stale value across batched or rapid updates.Migrations & tooling — dragging a 5-year codebase forward
This is the unglamorous senior work, and it's a real differentiator: you've actually run React 16 → 18, CRA/webpack → Vite, and TypeScript 3.8 → 5.9 on a live product that shipped every week. Lead with the why (a frozen, unmaintainable dependency graph), the method (incremental, bridged, scope-disciplined), and the honesty about debt you took on knowingly — that combination reads as senior.
The timeline at BestWe, all while the product kept shipping weekly:
| When | Migration |
|---|---|
| 2023 | React 16.12 → 18 · react-router 5 → 6 · react-redux 7 → 8 · Enzyme → React Testing Library |
| 2023–25 | TypeScript 3.8 → 4.4 → 5.9 — four deliberate moves over two years |
| 2024 | CRA + react-app-rewired (webpack) → Vite 5 · Jest stood up standalone |
| 2025–26 | Node → 24 · Vite 6 → 8 (Rolldown) · explicit chunking, brotli/gzip, network-aware preloading |
The three migrations — tell them as stories
Each is a `tension → decision → lesson` narrative. Hold the shape; go deep only where they push. The green line is the takeaway to land.
A.React 16 → 18 — where the framework was the easy part
The why. We were on React 16.12 (a 2019 release) and had become unable to upgrade anything else
— every modern npm install hit a peer-dep wall at react@^16, and security advisories
piled up against transitive deps pinned by react-scripts@3.4.1. The codebase wasn't broken, it
was frozen — and frozen codebases rot on a schedule you don't control. The real goal was never "React
18 features"; it was unfreeze the dependency graph.
The shape of the work. The version bump was ~8 lines of package.json. The branch that
closed around it was 719 files changed, +20k / −37k lines, ~133 commits over six weeks. That ratio
is the story: the bump is a one-liner, then you spend a month discovering what quietly depended on the
old behaviour.
The decision that made it reviewable — a bridge, not a big bang. react-router v6 removed
withRouter, but we had a long tail of class components taking history/location/match
as props. I added react-router-dom-v5-compat so v5 and v6 routing could coexist in one
tree during the migration — the single decision that turned an unmergeable 700-file rewrite into a branch that
could keep taking merges from development for six weeks. I also wrote a replacement
withRouter HOC over the v6 hooks.
The dead end I own. My first HOC bundled everything under one router prop (the "clean" v6
shape) — which forced every consumer to be rewritten to props.router.navigate. Five weeks later I
flattened it so each call site changes by one token:
- return <Component {...props} router={{ location, navigate, params }} />;
+ return <Component {...props} navigate={navigate} location={location} params={params} />;
// call site: history.push(x) → navigate(x) — one token, not a prop-path rewrite
Lesson: in a migration, the API that minimises downstream diff wins, even when it reads worse in isolation. I'd optimised the HOC's aesthetics over the 40 call sites that consumed it.
The Enzyme wall. enzyme-adapter-react-16 is the whole problem — Enzyme never shipped a
React 18 adapter. Enzyme tests reach into internals (shallow, instance state); RTL asserts on
rendered output. Different philosophies, no mechanical port. I audited and found most protected
implementation, not behaviour (snapshot-heavy chart internals), and deleted ~24k lines of test
code. Deleting tests during a migration is a real risk, taken deliberately
— and what I'd do differently is rebuild behavioural coverage on the critical paths before pulling the
old suite, so the migration itself is guarded. We paid for that ordering: a branch literally named
bugfix/the-bugs-found-after-react-update, because 18's automatic batching changed when
state flushes and old sync-ish code surfaced ordering bugs and effect double-fires. Visual bugs live in the
tail — because visual bugs don't throw.
Scope discipline (the Node ping-pong). The log shows Node rolled 14→18 then back to 14, twice, during the React branch. That looks like thrash; it was the opposite — bundling a runtime bump into a 719-file change means every CI failure has two possible causes. I kept the simultaneously-moving variables at one, and did Node 20 later as its own three-commit piece. StrictMode got the same honesty: it double-invokes effects in dev to surface unsafe cleanup, it lit up real bugs but hurt delivery, and I only enabled it for good ~18 months later once the debt was paid — StrictMode isn't a switch, it's a standard; turning it on before you can satisfy it just trains the team to ignore warnings.
Payoff: within a year we could bump axios, jsdom, react-router, d3, cypress and the whole eslint toolchain for CVEs — none of which was possible before.
B.CRA/webpack → Vite — the one with the visible payoff
The why. We were on react-scripts@5 + react-app-rewired +
config-overrides.js — i.e. webpack we couldn't see or configure, patched by a library whose whole
job is to monkey-patch a config CRA hides, while CRA itself went unmaintained. Two concrete pains: (1) the
dev loop — webpack bundles the entire app before serving the first byte, and on ~1,400 components plus
visx/vis-network/d3/gsap that was slow enough that people context-switched while waiting; and (2)
configurability — every chunking/asset/env question was a fight with react-app-rewired.
Why Vite is fast (say this). In dev, Vite serves native ESM unbundled — the browser requests
modules on demand — and pre-bundles only node_modules once with esbuild (Go-based, far
faster than a JS bundler). So dev start and rebuild-on-save stop scaling with app size. For production
it bundles with Rollup (Rolldown as of Vite 8). The trade-off you must name: the fast dev transform
(esbuild) doesn't type-check.
The migration itself — 25 files, +3,979 / −1,120, because a bundler swap is mostly deletion:
react-scripts/react-app-rewiredout;vite,@vitejs/plugin-react,vite-tsconfig-pathsin.public/index.html→ a rootindex.htmlwith<script type="module" src="/src/index.tsx">— Vite treats HTML as the entry graph, not a template.- build script
react-app-rewired build→tsc -b && vite build— splitting the typecheck out (incremental project references) from the bundle, so each does one job and the typecheck caches independently. react-loadable→React.lazy(abandoned + totally webpack-coupled; one loader file went 177 → 63 lines — deleting a dep and shrinking the file is the good kind of diff).
The compromises I took knowingly — and would name in the interview: vite-plugin-node-polyfills
because some transitive dep assumed global (webpack polyfilled it silently, Vite correctly
doesn't); vite-plugin-environment to keep existing process.env.REACT_APP_* call sites
alive instead of a large, boring, regression-prone rewrite to import.meta.env —
deliberately not migrating something is a legitimate migration decision.
The subtle bug: HMR. "HMR doesn't work, it just reloads" looked like a tooling bug and was actually a code-organisation constraint: React Fast Refresh can only hot-swap a module if it can prove the module only exports components. A file exporting a component plus a helper const plus a type escalates to a full page reload — and you lose all app state, brutal in a multi-step, streaming app. Fix: split mixed-export modules — a change with zero behavioural diff, hard to justify in review until you've felt the reload.
Resilience that CRA hid. Losing CRA's browser error overlay (esbuild doesn't type-check) meant type
errors reached CI instead of the dev's screen — so I added vite-plugin-checker (tsc + ESLint in a
worker with an overlay), tuned to errors only because a permanently-red overlay is a
permanently-ignored one. And React.lazy made chunk fetches a runtime failure mode (flaky wifi, or
a stale index.html pointing at last deploy's hashed filenames → blank screen), so I rebuilt the
loader with retry + timeout:
export const customLoad = (importFunc, timeoutMs = 5000) => lazy(
() => retry(() => Promise.race([
importFunc(),
timeout(timeoutMs).then(() => { throw new Error('Component took too long to load'); }),
])), // 5 retries, 1s apart, each racing a 5s timeout
);
The same fix repaired an ErrorBoundary that itself crashed on an unguarded
errorInfo.componentStack read — an error boundary that throws is worse than none; you lose the
error and the report.
Lesson: a bundler swap is mostly deletion, but the long tail is the test runner —
your bundler moved to ESM and Jest didn't. Standing Jest up standalone brought months of ESM-interop
pain (ESM-only packages Jest can't parse, lodash-es mapped back to lodash for tests,
d3 path-mapped so barrel imports don't time out). Budget for it explicitly. Also:
frontend build config is deployment config — I had to move Vite's default assets/ output
to avoid colliding with the backend's asset path.
C.TypeScript 3.8 → 5.9 — four moves over two years
A different animal. No runtime risk — nothing ships differently — but every minor version tightens
inference, so a codebase that's been accreting any since 2019 lights up with hundreds of new
errors the moment you bump. And unlike a React bug, a type error blocks the build, which blocks everyone.
That's why I did it in four deliberate moves, not one leap: 3.8 → 4.4 (rode in on the React branch,
required by @typescript-eslint@5 and @types/react@18), rolled back to unblock builds,
restored once stable → 4.9 → 5.9.
The strict decision I'd defend in any review. I turned strictness off during a
TypeScript upgrade:
- "strict": true,
+ "strict": false,
+ "noErrorTruncation": true,
strict: true was nominally set, but the code was full of implicit-any escapes older TS never
caught and 4.4 did. Options: (1) keep strict and fix every error → the React branch grows by hundreds more
files and nobody ships product while it's open; or (2) strict: false, ship, pay down
incrementally. I took (2) and paired it with noErrorTruncation: true — the part I actually
care about. Truncated errors ("…and 47 more") are why people give up on type errors; making every error fully
legible was a bet that the team fixes more types with strictness off and errors readable than with
strictness on and errors unreadable. It paid: three engineers have chipped at type errors ever since.
It's still false today — the honest open item: the ratchet turned, but it hasn't
finished turning.
The moduleResolution two-step. The big coordinated bump (TS 5.9 + Node 24 + Vite 6) first
set moduleResolution: node16, then four days later:
- "module": "node16", "moduleResolution": "node16",
+ "module": "ESNext", "moduleResolution": "bundler",
node16 makes TS emulate Node's ESM/CJS resolution — correct if Node runs your code,
wrong if a bundler does: it demands explicit file extensions and gets strict about package.json
exports maps, and it rejected imports Vite resolves fine. bundler (new in TS 5.0)
exists for exactly this: let the type-checker resolve modules the way the bundler actually will.
Getting it wrong for four days is a neat illustration of why the TS 5 upgrade had to follow the Vite
one — moduleResolution: bundler isn't even a sane option while your bundler is CRA's hidden webpack.
The config that fell out — every line is downstream of a migration:
| Setting | Then → Now | Why |
|---|---|---|
target | es5 → ESNext | Vite downlevels via browserslist; no reason to ship ES5 to the bundler |
jsx | react → react-jsx | the new JSX transform — no more import React just for JSX |
isolatedModules | — → true | required by Vite: esbuild transpiles file-by-file with no cross-file type info |
noEmit | — → true | tsc is now a checker, not a compiler — Vite emits |
Lesson: isolatedModules is the constraint that most shaped day-to-day
code afterwards — it forbids re-exporting a type without export type, and understanding
why it's required (a per-file transpiler can't see other files' types) is understanding why Vite is
fast.
Migration method — the principles to state aloud
If they ask "how do you approach a big migration?", these six lines are the senior answer. Each is backed by a concrete moment above.
The version bump is 1% of the work
8 lines of
package.json cost six weeks. Estimate from the codebase's coupling to the old thing, never
from the size of the version diff.
Bridges beat big bangs
If a migration can't be done
incrementally, the first task is to build the thing that makes it incremental — the v5-compat shim and
custom withRouter are what let a 700-file branch keep taking merges.
Cut scope ruthlessly, even mid-flight
Rolling Node back twice looked like thrash; it kept the simultaneously-moving variables at one, so every CI failure had a single cause.
Turn gates into ratchets
strict:false +
noErrorTruncation:true: don't let a quality bar block the migration, but make the violations
maximally visible so they get fixed continuously.
Name the compromises out loud
Node polyfills, kept
process.env, 24k deleted tests — all debt taken knowingly. The failure mode isn't taking the debt;
it's taking it silently, so no one knows it's outstanding.
Migrations are liberation, not features
Nothing shipped a user-visible win on merge day. What it bought: patch a CVE in an afternoon, ride four Vite majors without drama, and actually reason about our own bundle.
Anticipated Q&A on migrations
Q.What actually changed from React 16 to 18?
useTransition/useDeferredValue to mark non-urgent updates, the new root API
(createRoot replacing ReactDOM.render), and Suspense improvements for SSR streaming.
Plus StrictMode now double-invokes effects in dev, and useId for stable SSR ids. The
practical migration pain is the first two: they change when updates flush, so code written against 16's
synchronous-ish behaviour surfaces ordering bugs and effect double-fires.Q.Why move from webpack/CRA to Vite — how is it faster?
node_modules once with
esbuild, so start/rebuild stop scaling with app size. Prod: Vite bundles with Rollup (Rolldown in
v8). Trade-offs to name: dev and prod use different engines, and the esbuild dev transform doesn't
type-check — so you add a checker plugin, and you must set isolatedModules. It's not "faster for
free," it's a different architecture with its own constraints.Q.How do you run a large migration without freezing the product?
main. Change one axis at a time — don't bundle a
Node bump into a React bump. Take necessary debt knowingly and visibly (ratchets, not gates). And rebuild
critical-path behavioural test coverage before deleting the old suite, so the migration itself is guarded.Q.What are moduleResolution: "bundler" and isolatedModules?
bundler (TS 5.0+) tells the type-checker to resolve imports the way a bundler
does — no required file extensions, respects exports maps — versus node16, which
emulates Node's runtime ESM/CJS rules and is wrong when a bundler, not Node, runs your code.
isolatedModules guarantees every file can be transpiled alone with no cross-file
type info (which is how esbuild/Vite get their speed); it forbids things that need whole-program knowledge, e.g.
re-exporting a type without export type.Q.Enzyme → React Testing Library — how, and why delete tests?
shallow, instance state), RTL asserts on rendered output — different
philosophies. So you audit what each test protects: keep/rebuild the ones covering behaviour on critical
paths, drop the internals-coupled snapshots that only pin implementation. The discipline I'd add: rebuild the
behavioural coverage before removing the old suite, so you're never unguarded mid-migration.Q.How did you handle code splitting and chunking after CRA?
react-loadable (abandoned, webpack-coupled) → React.lazy +
Suspense, wrapped with retry + timeout so a failed chunk fetch (flaky network, or a stale
index.html after a deploy) doesn't blank the screen. Then explicit manual chunks: isolate
vendor-react/vendor-redux (they change rarely, so an app-code deploy doesn't
invalidate them in users' caches — cache stability), and split heavy viz libs (d3, vis-network) into
their own chunks so screens without charts don't download them. Chunk priority matters because a module
matching several rules must land in exactly one.Patterns you're "supposed to know"
Two buckets: classic software patterns that show up in JS, and React-specific composition patterns. You rarely have to name them — but recognising them lets you say "this is basically the observer pattern," which reads as senior.
General patterns in JS
Encapsulation
Closures (or ESM) expose a public API and hide internals. The base pattern behind most libraries.
One instance
A shared instance (a config store, a cache). In ESM a module is effectively a singleton.
Subscribe → notify
Subjects publish; listeners react. Powers event emitters, state stores, and React's own updates.
Create without new
A function returns configured objects — hides construction and branching.
Swap behaviour
Pass the algorithm in (a comparator, a formatter) so callers pick behaviour at runtime.
Intercept access
JS Proxy traps get/set —
reactive state (Vue, MobX, Valtio) is built on it.
// Observer / PubSub — a tiny type-safe event emitter
type Handler<T> = (payload: T) => void;
class Emitter<Events extends Record<string, unknown>> {
private listeners: { [K in keyof Events]?: Set<Handler<Events[K]>> } = {};
on<K extends keyof Events>(event: K, fn: Handler<Events[K]>): () => void {
(this.listeners[event] ??= new Set()).add(fn);
return () => this.listeners[event]?.delete(fn); // unsubscribe
}
emit<K extends keyof Events>(event: K, payload: Events[K]): void {
this.listeners[event]?.forEach(fn => fn(payload));
}
}
const bus = new Emitter<{ login: { userId: string }; logout: void }>();
const off = bus.on('login', ({ userId }) => console.log('hi', userId));
bus.emit('login', { userId: 'u1' });
off();
React composition patterns
Share logic
The modern default — extract stateful
logic (useDebounce, useFetch, useLocalStorage) and reuse it, no wrapper
components.
Shared implicit state
<Tabs><Tabs.List/><Tabs.Panel/> — parent holds state via context, children read it.
Flexible, declarative APIs.
Inject dependencies
Context provides theme/auth/store to a subtree without prop-drilling.
Older logic-sharing
A function-as-child or a wrapping component. Mostly superseded by hooks, but you'll see them in libraries.
Split data & UI
One component fetches/holds state, a dumb child just renders props — easier to test and reuse.
Invert control
Let consumers hook into or override how state transitions happen — advanced library API design (Downshift).
// Compound components via context
const TabsCtx = createContext<{ active: string; setActive: (id: string) => void } | null>(null);
function Tabs({ defaultId, children }: { defaultId: string; children: React.ReactNode }) {
const [active, setActive] = useState(defaultId);
return <TabsCtx.Provider value={{ active, setActive }}>{children}</TabsCtx.Provider>;
}
function Tab({ id, children }: { id: string; children: React.ReactNode }) {
const ctx = useContext(TabsCtx)!;
return (
<button aria-selected={ctx.active === id} onClick={() => ctx.setActive(id)}>
{children}
</button>
);
}
Tabs.Tab = Tab;
// <Tabs defaultId="a"><Tabs.Tab id="a">A</Tabs.Tab><Tabs.Tab id="b">B</Tabs.Tab></Tabs>
The small live-build tasks
Round 1 often ends with a 15-minute "write this" task in a shared editor. They're checking clean, correct, typed code — edge cases and API design, not cleverness. Here are the ones that recur, fully worked so you can practise typing them from memory.
A typed localStorage read/writer
A frequent ask ("wrap localStorage with types"). Good answers handle: JSON (de)serialisation,
the SSR/private-mode case where localStorage throws, a default value, and a typed key map.
// Describe your storage shape once — keys are type-safe, values are typed per key.
interface StorageSchema {
'auth.token': string;
'user.prefs': { theme: 'light' | 'dark'; lang: string };
'cart': { id: string; qty: number }[];
}
function createStorage<Schema extends Record<string, unknown>>(
store: Storage = window.localStorage,
) {
const available = (() => {
try { const k = '__t'; store.setItem(k, k); store.removeItem(k); return true; }
catch { return false; } // SSR, private mode, quota, disabled
})();
return {
get<K extends keyof Schema & string>(key: K, fallback: Schema[K]): Schema[K] {
if (!available) return fallback;
const raw = store.getItem(key);
if (raw === null) return fallback;
try { return JSON.parse(raw) as Schema[K]; }
catch { return fallback; } // corrupt value → don't crash the app
},
set<K extends keyof Schema & string>(key: K, value: Schema[K]): void {
if (!available) return;
try { store.setItem(key, JSON.stringify(value)); }
catch { /* quota exceeded — swallow or evict */ }
},
remove(key: keyof Schema & string): void {
if (available) store.removeItem(key);
},
};
}
const storage = createStorage<StorageSchema>();
storage.set('user.prefs', { theme: 'dark', lang: 'en' }); // ✅ typed
const prefs = storage.get('user.prefs', { theme: 'light', lang: 'en' });
// storage.set('user.prefs', { theme: 'blue' }); // ❌ compile error
localStorage throws in private mode and doesn't exist during SSR", "I take a fallback so
callers never get null", "keys are typed against a schema so you can't fetch a value at the wrong
type." That narration is what earns the senior grade.A permissions / RBAC helper
"Given a user and a resource, can they do X?" This tests modelling and clean boolean logic. A simple, honest answer is role → allowed-action set, with an escape hatch for ownership rules.
type Role = 'admin' | 'editor' | 'viewer';
type Action = 'read' | 'create' | 'update' | 'delete';
interface User { id: string; roles: Role[]; }
interface Doc { id: string; ownerId: string; }
// Static grants per role. Functions allow ownership / contextual checks.
type Rule = boolean | ((user: User, resource: Doc) => boolean);
const permissions: Record<Role, Partial<Record<Action, Rule>>> = {
admin: { read: true, create: true, update: true, delete: true },
editor: { read: true, create: true, update: true,
delete: (user, doc) => doc.ownerId === user.id }, // editors delete only their own
viewer: { read: true },
};
function can(user: User, action: Action, resource: Doc): boolean {
return user.roles.some((role) => {
const rule = permissions[role]?.[action];
if (rule === undefined) return false;
return typeof rule === 'function' ? rule(user, resource) : rule;
});
}
// can(editor, 'delete', ownDoc) → true
// can(editor, 'delete', othersDoc) → false
// can(viewer, 'update', anyDoc) → false
A useLocalStorage hook (ties it together)
They love asking you to turn the storage helper into a React hook — it tests hooks, generics, and lazy init.
function useLocalStorage<T>(key: string, initial: T) {
const [value, setValue] = useState<T>(() => { // lazy init: read once
try {
const raw = window.localStorage.getItem(key);
return raw !== null ? (JSON.parse(raw) as T) : initial;
} catch { return initial; }
});
useEffect(() => {
try { window.localStorage.setItem(key, JSON.stringify(value)); }
catch { /* ignore quota / unavailable */ }
}, [key, value]);
return [value, setValue] as const; // tuple, like useState
}
// const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light');
The one you got asked: useDebounce for a live address
The task: an input updates address on every keystroke, and a heading below should show
the address delayed — only once typing settles. It looks like a five-minute question and it's really a
hooks mental-model question: a debounced value in React is not a function you call, it's a piece
of state that lands later.
// ❌ The attempt — four separate things are wrong
const useDebounce = (value, wait = 0) => {
let timeout; // (2) new variable every render — clears nothing
async function fn() {
if (timeout) clearTimeout(timeout);
let val;
await function () { // (3) awaits a function *object*, never calls it
timeout = setTimeout(() => { val = value; }, wait);
};
return val; // always undefined
}
return fn(); // (1) returns a Promise, during render
};
const debouncedAddress = useDebounce(address); // (4) no state → nothing re-renders
Why each part fails
- It returns a Promise, and renders it.
fnisasync, sofn()returns a Promise immediately.{debouncedAddress}is therefore a Promise, not a string — React renders nothing useful for it. Render must be synchronous: a hook has to return the value it has now, and trigger a re-render when a better one arrives later. - The timer id doesn't survive a render.
let timeoutis a plain local, recreated from scratch on every render, so it's alwaysundefinedwhen the next keystroke checks it. TheclearTimeoutnever clears anything, and every keystroke leaves a live timer behind. Anything that must persist across renders has to live in state, a ref, or an effect's closure — never a bare local. await function () {…}never runs the function. That's a function expression, not a call.awaiton a non-Promise just resolves to the value itself on the next microtask — so the body never executes, the timeout is never scheduled, andvalstaysundefined. (Even written asawait (async () => …)()it would still be wrong:valis assigned inside a latersetTimeoutcallback, long afterreturn valalready ran.)- Nothing tells React to re-render. This is the deep one. Even if the timer fired and set
valperfectly, React has no idea anything changed — a render only happens on a state update. A debounced value must be state.
// ✅ The idiomatic fix — debounce a *value*
import { useEffect, useState } from "react";
function useDebounce(value, wait = 500) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), wait);
return () => clearTimeout(id); // ← the whole trick: next keystroke cancels this one
}, [value, wait]);
return debounced;
}
export default function App() {
const [address, setAddress] = useState("");
const debouncedAddress = useDebounce(address, 500);
return (
<div className="App">
<input value={address} onChange={(e) => setAddress(e.target.value)} />
<h2>Address: {debouncedAddress}</h2>
</div>
);
}
Read the effect as a sentence: "whenever value changes, schedule a write in 500 ms — and if it
changes again before then, throw the old schedule away." The cleanup running before every re-run is what
makes this a debounce; there's no extra bookkeeping. The id is safe as a plain local because it
lives in the effect's closure, and the exact same closure's cleanup is the only thing that reads it.
wait = 0, which debounces nothing — 300–500 ms is the sane default for
search-as-you-type. Seed state with value, not "", so the first paint isn't
blank for no reason."Didn't I need a useRef here?"
Your instinct was half right, and it's worth knowing exactly which half — this is a great thing to be able to explain in the room.
For a debounced value: no. The timer id needs to survive from "the effect runs" to "the effect cleans up," and the closure already does that — the cleanup is the same closure. A ref would work, but it's strictly more machinery for the same result.
For a debounced callback: yes. The moment you want debouncedSearch(query) — a function you
call from an event handler rather than a value you read — there's no effect to hold the closure, and the timer
has to live across renders. That's exactly what a ref is for: a mutable box that persists across renders and
doesn't cause one.
// ✅ Debounce a *callback* — this is where the ref earns its place
function useDebouncedCallback(fn, wait = 500) {
const timerRef = useRef(null);
const fnRef = useRef(fn);
// keep the latest fn without re-creating the debounced function → no stale closure
useEffect(() => { fnRef.current = fn; }, [fn]);
// cancel a pending call if we unmount mid-wait
useEffect(() => () => clearTimeout(timerRef.current), []);
return useCallback((...args) => {
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => fnRef.current(...args), wait);
}, [wait]);
}
// const onSearch = useDebouncedCallback((q) => fetchResults(q), 400);
// <input onChange={(e) => onSearch(e.target.value)} />
AbortController or a request-id check to ignore
stale results. Saying that unprompted is the whole senior signal on this question.The FizzBuzz mystery — "some numbers didn't log in non-strict mode"
Standard FizzBuzz for 1–100, some numbers silently missing — but the same logic working under strict mode. Silence is the clue: sloppy mode's whole personality is failing quietly where strict mode speaks up. Two mechanisms produce exactly this, depending on which "strict mode" was in play.
// the baseline everyone writes
for (let i = 1; i <= 100; i++) {
let out = "";
if (i % 3 === 0) out += "Fizz";
if (i % 5 === 0) out += "Buzz";
console.log(out || i);
}
If it was JS strict mode: an implicit global ate your counter
In sloppy mode, assigning to an undeclared variable doesn't throw — it silently creates a property on
window. Strict mode makes the exact same line a ReferenceError. So this runs "fine" in
a script or the console, and refuses to run at all under "use strict" (or inside a module, which is
always strict — every React file, every ES module):
function fizzbuzz(n) {
for (i = 1; i <= n; i++) { // no let/var → this is window.i
console.log(label(i));
}
}
function label(x) {
for (i = 0; i < 3; i++) { /* any inner loop, also undeclared */ }
return x % 15 === 0 ? "FizzBuzz" : x % 3 === 0 ? "Fizz" : x % 5 === 0 ? "Buzz" : x;
}
Both loops share one global i. The inner loop leaves it at 3 every time, so the outer
counter jumps around instead of walking 1→100 — numbers get skipped, some repeat, and nothing errors. The moment
you declare the variables properly (which strict mode forces you to do), each loop gets its own scoped counter
and the skips vanish. That's why "fixing it for strict mode" fixed the bug — the declaration was the fix;
strict mode just refused to let you ship without it.
var and implicit globals land on window, where names like name,
length, top, and status already exist with special behaviour — some are
read-only or coerce your value to a string. In sloppy mode a write that fails is silently ignored; in
strict mode it throws a TypeError and you find it in one second. Same lesson: strict mode doesn't
change your logic, it stops hiding the failure."use strict"; at the top of the
exact snippet that misbehaves. If it now throws a ReferenceError: i is not defined (or a
TypeError on an assignment), you've found it — an implicit global was being written by two places at
once. If it stays silent, it's the React reading below.If it was React <StrictMode>: you were reading the console wrong
If the FizzBuzz ran in a component body in a sandbox, "strict mode" probably meant React's
<StrictMode>, and then the direction is inverted from what it looks like. StrictMode
double-invokes your render in dev to surface side effects — so the loop runs twice and you get 200 logs.
Combined with a console that collapses consecutive identical messages into one line with a
×2 count badge (Chrome does this; sandbox consoles also cap and batch output), the two runs look
very different, and lines genuinely disappear from view without disappearing from the program.
The real lesson underneath: a console.log loop in a render body is a side effect in the wrong
place — that's exactly the thing StrictMode is designed to make visible. Put it in an effect
(useEffect(() => { … }, [])) or an event handler and the "mystery" goes away, because you're no
longer relying on how many times React chose to render you.
Other likely one-shots
Keep these in muscle memory — any could be the closer.
- retry with backoff: a loop that awaits, catches, waits
2**attempt * basems, and rethrows after N tries. Mention jitter to avoid thundering herds. - concurrency limiter / promise pool: run an array of async tasks N-at-a-time — tests you understand
Promises beyond
Promise.all. - LRU cache: a
Map(insertion-ordered) — on get, delete+re-set to mark recent; on set over capacity, delete the first key. O(1). - deep equal / deep clone: recurse objects/arrays; know that
structuredCloneexists for clone and whyJSONround-tripping is lossy. - typed
fetchwrapper: add timeout viaAbortController, throw on!res.ok, and parse to a generic<T>.
// retry with exponential backoff + jitter
async function retry<T>(fn: () => Promise<T>, tries = 4, base = 200): Promise<T> {
let lastErr: unknown;
for (let attempt = 0; attempt < tries; attempt++) {
try { return await fn(); }
catch (err) {
lastErr = err;
const delay = base * 2 ** attempt + Math.random() * base; // jitter
await new Promise((r) => setTimeout(r, delay));
}
}
throw lastErr;
}
Rapid-fire: browser, CSS & web platform
Round 1 sometimes mixes in platform basics. One or two crisp sentences each is plenty.
Q.What is CORS?
Access-Control-Allow-Origin (and friends) response headers. For non-simple requests the browser
sends a preflight OPTIONS first. It's enforced by the browser, not the server, and
it protects users — it isn't about hiding your API.Q.CSS specificity & the box model?
!important overrides (avoid it). Box model: content → padding → border → margin; set
box-sizing: border-box so width includes padding+border.Q.Why does a flex item overflow its container (e.g. long text won't shrink)?
min-width: auto (or min-height: auto in a
column), which refuses to shrink them below their content size — so a long child or text blows past the
container. The fix is min-width: 0 on the flex item (or min-height: 0 for a
column), which lets it shrink; then overflow: hidden/auto and text-overflow: ellipsis
control clipping. Key nuance: overflow alone treats the symptom — the root cause is the min-size
default.Q.How do you improve web performance / Core Web Vitals?
font-display: swap, cache aggressively, avoid layout shift by reserving space (helps CLS),
prioritise the hero image/critical CSS (LCP), and keep the main thread free to cut input delay
(INP). Measure with Lighthouse and real-user metrics.Q.Accessibility basics you always do?
button, nav, headings in order), labels tied to
inputs, keyboard operability and visible focus, sufficient colour contrast, alt text, and ARIA only
to fill gaps semantics can't (roles/states on custom widgets). Test with keyboard-only and a screen reader.Q.How do you store auth tokens — cookie vs localStorage?
localStorage is readable by any JS, so it's exposed to XSS. An
HttpOnly, Secure, SameSite cookie can't be read by JS and is the
safer default for session tokens (pair with CSRF protection). Never put secrets or long-lived tokens in
localStorage if you can avoid it.One-line recall
Closing the round well
The last five minutes are yours. Good questions signal seniority and let you screen the team too.
About the work
"What does the frontend architecture look like today, and what's the biggest pain point you'd want a senior to fix first?"
About the team
"How do decisions get made — RFCs, tech leads, consensus? How is code reviewed?"
About growth
"What does success look like at 3 and 6 months for this role?"
About quality
"What's your testing and release story — how confident are you shipping on a Friday?"