React interviews for 10+ years of experience skip what a hook is and go after what experience teaches: why concurrent rendering makes pure renders and consistent stores matter, where the reconciler keeps or throws away state, how portals, refs and effects behave at the edges, what streaming and server functions change, how to choose rendering and team boundaries across a big product, and how you lead migrations, standards and hard calls. It is written for React engineers with around eight to fifteen years of front-end work, interviewing for senior, staff or lead roles. Each question shows what the interviewer is checking, a shape for your answer and a sample you can adapt with your own stories.
Search all questions by round, difficulty and level, or save the ones you want to practice.
React tree: events fired inside a portal bubble to React ancestors as if the portal were rendered in place, so the card's onClick runs.
DOM tree: a click-outside check with node.contains looks at the real DOM, where the menu isn't inside the dropdown, so the click counts as outside.
Fix: make the click-outside check include the portal's own node, and stop propagation in the menu if the card must not react.
“A portal moves where the DOM nodes live, not where the component sits in the React tree. React's own events follow the React tree, so a click on a menu item bubbles to the dropdown and then to the card, exactly as if the menu were rendered inline. That's why the card's onClick fires. The click-outside logic is a native mousedown listener on the document that asks whether the dropdown's DOM node contains the target. In the real DOM the menu hangs off body, not the dropdown, so the answer is no, the menu closes on mousedown, and the item's click never lands. The two systems disagree. My fix is to keep a ref to the portal's container and treat a click inside either node as inside, and to call stopPropagation in the menu if the card really shouldn't react. For nested portals, like a submenu, each layer registers its node with the one above through context.”
Assuming events follow the DOM tree, or patching it with a setTimeout before closing the menu.
Rule: when the element type at a position changes, React discards the old subtree and builds a new one; it doesn't search for moved children.
Here: the player moved from being a direct child to being inside a new div, so its position changed and it remounted.
Fix: keep the tree shape stable, for example always render the wrapper and change its class, or render the banner as a sibling.
“React's diff is deliberately simple. It compares the old and new trees position by position, and if the type at a spot changes, it throws away everything under it and mounts fresh. It doesn't look further down for the same component to move it. When the banner shows, the player goes from being a direct child to being a child of a new div, so from React's point of view the old player is gone and a new one appeared. The video element is recreated and starts from zero, and any state in the player is lost. The fix is to keep the tree shape the same in both cases: always render the wrapper and toggle a class or style, or put the banner next to the player instead of around it. For anything expensive to recreate, like a player or a map, I treat its position in the tree as part of its contract.”
Blaming the video element or the browser, without seeing that the tree shape changed.
The comparison: React compares old and new state with Object.is, and if they match it skips the children and commits nothing.
The extra call: in some cases React has to call the component once more before it can confirm nothing changed, then discards that render.
The real trap: a new object or array with the same contents is a new reference, so it always re-renders.
“React compares the new value with the old one using Object.is, and if they're the same it skips re-rendering the children and commits nothing, so no effects run. But it can't always tell in advance. In some cases it has to call the component function one more time before it can confirm nothing changed, and then it throws that render away. So one extra call to the body, with nothing committed and no children rendered, is expected and harmless, as long as the render is pure. It only hurts if the render has side effects, like logging, which is one more reason render must be pure. The more common trap is the opposite one: setting a new object or array with the same contents. Object.is sees a new reference, so it always re-renders. In reducers and stores I return the existing state when nothing changed, and I never write logic that counts renders.”
Claiming React never calls the component when the value is the same, or writing logic that depends on render counts.
Tearing: a concurrent render can pause between components, and the store can change in that gap, so one render reads two versions.
The hook: useSyncExternalStore checks that the snapshot didn't change during the render, and if it did, React renders again synchronously so the screen stays consistent.
The cost: store updates can't be treated as transitions, so a store change that makes content suspend shows the fallback instead of keeping the old screen.
“That's tearing. In a concurrent render React can pause between components to let the browser do other work. A store outside React can be changed during that pause, say by a socket message, so the components rendered before the pause read the old value and the ones after read the new one, and both end up on screen. The effect-based subscription can also miss a change that happens between render and the effect running. useSyncExternalStore closes both gaps. React reads the snapshot during render and checks it again before committing. If the store changed mid-render, it throws that work away and renders again synchronously, so everything matches. The price is that updates from that store are always urgent. They can't be time-sliced, and if one makes something suspend, React shows the fallback rather than keeping the old content the way a transition would. So I keep slow, suspending data out of these stores.”
const store = { state: { count: 0 }, listeners: new Set() };
function subscribe(callback) {
store.listeners.add(callback);
return () => store.listeners.delete(callback);
}
function useCount() {
return useSyncExternalStore(subscribe, () => store.state.count);
}
Saying that wrapping the store in context fixes it, or not knowing that a concurrent render can pause between components.
What it gives: a transition render can be interrupted by urgent updates, but React only checks between units of work, roughly between components.
Where it fails: one component doing heavy work in its body, like crunching thousands of points, still blocks until it finishes.
Next steps: profile, split the heavy work into smaller memoised pieces, draw less, or move the computation off the main thread.
“A transition doesn't make rendering cheaper. It marks an update as interruptible, so if the user drags again React can drop the half-finished render and start over. But React can only yield between units of work, which is roughly between components. If one chart component crunches ten thousand points in its body, that chunk runs to the end and the main thread is blocked the whole time. So on a slow phone the slider still waits. It also can't help if the slider's own value is updated inside the transition; that state has to stay urgent. My next step is the profiler, to see which component's render is long. Then I split the heavy part into smaller memoised pieces, cut the points down to what the screen can actually show, and if the maths itself is slow, move it to a worker or precompute it.”
Treating startTransition as a speed-up, or putting the input's own value inside the transition.
Why it happens: the tab's content suspended and the nearest boundary is high up, so everything under it swaps to the fallback.
Transitions: when navigation runs as a transition, React keeps already-visible content on screen instead of hiding it, and isPending lets you show a light indicator.
Boundary placement: wrap regions that make sense to load on their own, so a fallback never replaces the navigation or the layout.
“When something suspends, React finds the nearest Suspense boundary above it and shows that fallback. If the only boundary sits near the root, the whole layout, header and tabs included, is replaced by a spinner, which feels like a crash. Two changes fix it. First, I make the tab switch a transition. When a transition makes content that's already on screen suspend, React keeps the old content visible and waits, instead of hiding it. I use isPending from useTransition to dim the old tab or show a thin progress bar. Second, I place boundaries on purpose: one around the tab panel, so when a panel does need a fallback, like on first load, the skeleton sits inside the layout, and separate ones around slow widgets that shouldn't hold up the rest. Boundaries are a decision about what the user sees while waiting, so I agree them with design rather than scattering them.”
Adding more spinners or one boundary around the whole app, without knowing a transition keeps visible content.
Identity: an inline arrow is a new function on every render, and React treats a new ref callback as a new ref.
What React does: after each commit it detaches the old callback by calling it with null, then calls the new one with the DOM node.
Fix: give the callback a stable identity with useCallback, or keep the node in a plain ref and set up the observer in an effect.
“React compares ref callbacks by identity. An inline arrow is a brand new function on every render, so after each commit React detaches the old one by calling it with null and then calls the new one with the same DOM node. If my callback creates a ResizeObserver when it gets a node and disconnects it when it gets null, the observer is destroyed and rebuilt on every render. On a chart that re-renders on hover, that's constant churn, and if the null branch forgets to disconnect, observers pile up. The fix is a stable callback: wrap it in useCallback with only the dependencies that should really re-attach it, and read changing values like an onResize prop from a ref. Or I keep a plain object ref and do the observing in an effect. In recent React versions a ref callback can also return a cleanup function, which React calls instead of passing null.”
Blaming ResizeObserver itself, or not knowing that a new ref callback means a detach and a re-attach.
Timing: React updates refs during the commit and sets them to null on unmount, before the effect cleanups run.
The bug: by cleanup time containerRef.current is already null, so the cleanup throws and the listener is never removed.
Fix: copy ref.current into a local variable inside the effect and use that same variable in the cleanup.
“The cleanup runs later than people think. React updates refs as part of the commit, and on unmount it sets the ref to null. Only after that do the effect cleanups run. So by the time my cleanup reads containerRef.current, it's null, and calling removeEventListener on it throws. The listener is never removed, so the handler and everything it closes over stay alive. The fix is simple: read the ref once inside the effect, keep it in a local variable, and use that variable in both the setup and the cleanup, so they always talk to the same node. The hooks lint rule warns about exactly this. One more trap: changing ref.current doesn't make the effect run again, so if the node can be swapped while the component stays mounted, I use a ref callback instead, or keep the node in state.”
useEffect(() => {
const node = containerRef.current;
node.addEventListener('scroll', onScroll);
return () => node.removeEventListener('scroll', onScroll);
}, [onScroll]);
Adding a null check in the cleanup and calling it fixed, while the listener stays attached.
Order: after a commit, React runs the children's effects before their parents' effects.
The bug: the child's effect runs while the parent's effect, which creates the bus, hasn't run yet.
Fix: create the object during render with a lazy state initializer and pass it down, keeping only real side effects in the effect.
“Effects run after the commit, and React runs them from the inside out: children's effects first, then their parents'. So on the first mount the child's effect runs while the parent's effect, which creates the bus, hasn't happened yet, and the child finds nothing. Layout effects follow the same order. The fix is to stop creating something the children need inside an effect. Creating a plain object isn't a side effect, so the parent can create it during render with a lazy state initializer, useState(() => createBus()), and pass it down through context. It exists before any child renders, and it stays the same object across renders. The parent's effect then only does the real side effects, like connecting the bus to a socket, and cleans them up. The broader lesson I share with teams is to never rely on effect order between components; if the order matters, the design needs changing.”
Adding a setTimeout in the child to wait for the parent.
Split the concerns: the connection depends on the URL; the handler is a value you only need to read when a message arrives.
Latest ref: keep the handler in a ref, update it after each commit, and let the socket callback read ref.current.
Subtle part: never assign the ref during render, because a render can be thrown away.
“The connection and the handler change for different reasons. The socket should only reconnect when the URL changes, but the handler changes every render because the parent creates a new function each time. So I keep the handler in a ref. A layout effect with no dependency array writes the latest onMessage into the ref after each commit, and the socket effect depends only on the URL and calls ref.current when a message arrives. It always calls the newest handler without reconnecting. The subtle part is where the ref gets written. It's tempting to assign it in the render body, but a concurrent render can be discarded, so the ref could hold a handler from a render that never reached the screen. Writing it in an effect ties it to committed renders. I might also ask the parent to wrap onMessage in useCallback, but the hook shouldn't depend on that.”
function useSocket(url, onMessage) {
const handlerRef = useRef(onMessage);
useLayoutEffect(() => {
handlerRef.current = onMessage;
});
useEffect(() => {
const ws = new WebSocket(url);
ws.onmessage = event => handlerRef.current(event);
return () => ws.close();
}, [url]);
}
Silencing the lint rule and leaving an empty-array effect that calls a stale handler.
Children is a prop: JSX between the tags becomes props.children, a new element object on every parent render.
Shallow compare fails: memo compares props by reference, so the new children object always counts as a change.
Better fixes: move the changing state down so the parent stops re-rendering, or pass the content in from a component above that doesn't change.
“memo does a shallow comparison of props, and children is just another prop. Every time the parent renders, the JSX between the Panel's tags runs again and produces new element objects, so props.children is a new reference and memo sees a change every time. The title string compares fine; the children don't. I could memoise the children with useMemo in the parent, but that's fragile, since one inline handler inside breaks it again. The better fix is usually structural. If the parent re-renders because of some fast-changing state, like hover or a timer, I move that state into a small component that wraps the Panel and receives the content as children from above. When only that wrapper's state changes, the children it received are the same objects as last time, so React skips them without any memo at all. Memo is my last tool, not my first.”
Adding memo, useMemo and useCallback everywhere without knowing that children is a prop.
Why: streaming sends the status and headers with the shell, the part outside the Suspense boundaries, and they can't change afterwards.
The bug: the product lookup sits inside a Suspense boundary, so the page learns the product is missing after the 200 has gone out.
Fix: run the lookups that decide status or redirects before rendering, or in the shell, and keep only secondary, slow parts inside boundaries.
“With streaming, the server sends the shell as soon as it's ready, meaning everything outside the Suspense boundaries, and the HTTP status and headers go out with that first chunk. Once they're sent, they can't change. Our product lookup was inside a boundary, so the server had already sent a 200 and the layout by the time it found out the product didn't exist. All it could do was stream an error message into the page, which crawlers treat as a soft 404. The rule I set afterwards is that anything that decides the response, like not found, a redirect or an auth check, runs before rendering starts, in the route's data loading or in the shell. Only the parts that can safely arrive late, like reviews or recommendations, sit inside Suspense boundaries. We lost a little of the early first byte on those pages, and that was the right trade.”
Thinking the server can still set a 404 after streaming has started.
What hydration expects: the first client render must produce the same markup the server sent, so React can attach to it.
Usual causes: times and time zones, random values, checks for window or storage during render, locale formatting, invalid HTML nesting, and extensions editing the page.
Fixes: make the first render match and update after mount, use useId for generated ids, and keep suppressHydrationWarning for one unavoidable piece of text.
“Hydration assumes the first client render produces exactly what the server sent, so React can reuse the HTML and just attach events. A time is the classic break: the server formats it in its own time zone, the browser in the user's, and the text differs. Other causes are random ids, checking window or local storage during render, locale-dependent number formatting, and invalid nesting like a div inside a p, which the browser's parser rearranges before React sees it. Browser extensions that inject markup cause some too. When React finds a mismatch it may throw away the server HTML for that part and render it on the client, which costs time and causes the flip. My fix is to render something stable on the server, then show the user-specific value in an effect after mount. For ids I use useId. suppressHydrationWarning is only for one element's text that truly can't match, never a blanket fix.”
Spreading suppressHydrationWarning around, or branching the markup on typeof window during render.
What it is: a function marked 'use server' is exposed as an endpoint the browser calls, and anyone can call it directly.
The hole: hiding the button is only UI; a user can replay the request with another project id.
Fix: inside the function, check who the user is, check they may delete that project, and validate every argument as untrusted input.
“It feels like calling a function, but underneath the framework turns it into an HTTP endpoint, and the button just sends a request to it. Anyone who can load the app can find that request in the network tab and send it again with a different project id. Hiding the button protects nothing. So I treat every server function like a public API route. Inside it, I read the session on the server, never trust a user id passed in as an argument, check that this user may delete this particular project, and validate the arguments, because the types in my code don't exist at runtime. I keep those checks in a shared data-access layer rather than in each action, so a new action can't forget them, and I add a test that calls the action as a normal user and expects a refusal.”
Believing a server function is private because it lives in server code or is only shown to admins.
Cause: an open tab still runs the old build, and its lazy imports ask for old hashed chunk files that the new deploy removed.
Prevent: keep previous builds' assets available for a while instead of wiping them on every deploy.
Recover: catch chunk load failures in an error boundary around lazy routes, reload once to get the new build, and guard against reload loops.
“Lazy-loaded routes are separate files with a content hash in the name. A user who opened the app before the deploy is still running the old build, so when they click to a new route, their code asks for an old chunk file. If the deploy replaced the whole assets folder, that file is gone and the import fails. Errors spike after every release and fade as tabs get refreshed. First I'd stop deleting old assets on deploy and keep a few previous builds' files on the server or CDN, which fixes most of it. Then I'd handle the rest: an error boundary around lazy routes that recognises a chunk load failure and reloads the page once, with a flag in session storage so it can't loop. For tabs that stay open for days, I'd have the app check for a new version now and then and offer a refresh at a safe moment, like a route change.”
Retrying the import in a loop, or telling users to clear their cache.
Name the pain: release coupling, slow builds or unclear ownership each have cheaper fixes than a runtime split.
Count the cost: duplicate dependencies, one React instance to share, screens drifting apart, cross-app state and harder performance work.
If you split: split by route or business area, one shell owns routing, login and the design system, shared libraries are singletons with a version policy, and teams talk through the URL and events.
“I'd start with what hurts. If it's one release train where any team's bug blocks everyone, a monorepo with clear module boundaries, owners per folder and independent test runs might fix it without splitting the runtime. Micro-frontends buy independent deployment, but they cost a lot: extra copies of libraries if you're careless, React that must be shared as one instance, screens that drift apart visually, and performance that nobody owns. If teams really need their own release schedule, I'd split along routes or business areas, never widgets on the same page. One shell app owns routing, login, error handling and the design system. React and the design system are shared singletons with a policy for upgrading them. Teams talk through the URL and a few documented events, not shared global state. And I'd set a performance budget per area, because total page weight is where these setups quietly fail.”
Choosing micro-frontends because they sound scalable, without naming the problem or the runtime cost.
Marketing: rarely changes and must be fast and crawlable, so build it static and serve it from the CDN.
Catalogue: too many pages to build ahead, so render on request behind a cache with revalidation, and stream slow parts like reviews.
Dashboard: personal, interactive and not for search, so a client-heavy app behind login, maybe with a server-rendered shell.
Across all: one design system and data layer, with written rules for what can be cached and for how long.
“I'd decide per area, from three questions: how fresh does the data need to be, does search need it, and how personal is it. Marketing pages change rarely and must be fast and crawlable, so I'd build them static and serve them from the CDN. The catalogue has too many pages to rebuild on every change, and prices and stock move, so I'd render on request with a cache in front and a short revalidation window, and stream the slower, less important parts like reviews and recommendations so the product details arrive first. Price and stock I'd keep fresh, possibly fetched on their own. The dashboard is personal and not for search, so a client-heavy app behind login is fine, maybe with a server-rendered shell for a quicker first paint. What I'd standardise across all three is the design system, the data layer and the caching rules, so a price never shows two values on two pages.”
Picking one rendering mode for the whole product, or server-rendering the logged-in dashboard just because it's the trend.
Pick few: standardise what crosses team lines or hurts users when it varies: design system, accessibility, performance budgets, error reporting, security rules.
Leave local: folder layout, small library choices and style that only affects one team.
Make it stick: put rules in shared configs, templates and CI checks, decide them with a rep from each team, and write down why.
“I'd look before deciding. I'd read a few codebases, gather incidents and pain points, and ask each team what slows them down. Then I'd standardise only what crosses team lines or hurts users when it varies: the design system, accessibility rules, a performance budget per page, error reporting, dependency and security policy, and the hooks lint rules. Everything else, like folder layout or small library choices, stays with the teams. To make it stick, rules live in tools rather than wiki pages: a shared lint and TypeScript config, a project template, and CI checks that fail a build on budget or accessibility regressions. Decisions go through a small group with one engineer from each team, written up with the reasoning, so it isn't me handing rules down. And I'd keep an exception process, because a standard with no way out gets ignored quietly.”
Writing a long style guide nobody enforces, or standardising everything down to folder names.
The problem: what you saw, and why it mattered to users and the business.
The case: data from your own product, a scoped proposal, and the feature it would cost.
The result: what was agreed, what shipped, and how you showed it worked.
“At my last company our main product page had become slow on mid-range phones, but nobody felt it on office laptops. I pulled our own field data, split by device, and lined it up with the funnel. Users on slower phones dropped off at that page far more often than users on fast ones. I didn't ask for a vague quarter of performance work. I proposed four weeks for one team with three concrete fixes: split the bundle for that route, stop loading the reviews widget up front, and take a heavy date library off the main path. I also named which feature would slip. Leadership agreed, on the condition that we showed the result. Load and interaction times dropped clearly on slower phones, and the drop-off there shrank. The cost was a delayed feature, and I added a performance budget to CI so we wouldn't need another rescue.”
Asking for time to clean up code with no link to users or the business.
What happened: the goal, your role, and how it went wrong.
Your mistake: one specific decision you'd make differently, not bad luck.
What changed: the habit or process you use now.
“I led building an in-house form engine. The idea was that product teams would describe forms in JSON and the engine would render them, with validation and layout built in. It worked well for the first few simple forms, so more teams adopted it. Then real requirements arrived: fields that depend on each other, custom widgets, unusual layouts. Each one added another option to the schema, and within a year the JSON had become a programming language nobody could debug, and teams waited on my team for every change. We eventually froze it and let teams build forms in plain React with shared field components. My mistake was designing a platform from three easy cases. I never tested it against the hardest form in the product, and I gave teams no escape hatch. Now I prove any abstraction on the ugliest real case first, and I always leave a way to drop down to plain code.”
Blaming other teams or shifting priorities, with no mistake of your own.
Find the pattern: group recent bugs to see which mistakes repeat.
Guardrails: enforce the hooks lint rules, add shared hooks for the common cases, and set clear review expectations.
Teaching: short sessions built on the team's own bugs, and pairing on the tricky ones.
“At my last company I grouped three months of front-end bugs and found most came from a handful of patterns: effects used to sync derived state, missing dependencies silenced with comments, and fetches with no cancellation. So I did three things. First, guardrails: we made the hooks lint rules errors instead of warnings, and any disable comment needed a written reason that reviewers could challenge. Second, shared tools: one data-fetching hook that handled cancellation and races, so nobody wrote that by hand again. Third, teaching from our own code: a thirty-minute session every two weeks walking through one real bug and its fix, and I paired with the two people newest to hooks. Within a couple of months that class of bug mostly dropped off our incident list. The cost was slower reviews for a few weeks, which the team accepted once they saw why.”
Saying you'd simply review every pull request yourself.
Real code: a short, realistic component with a bug and a performance issue, instead of trivia.
Probe decisions: ask why they chose an approach and what changes when the screen grows.
Signals: trade-offs named without prompting, knowing when not to use a tool, and saying what they'd measure.
“I avoid definition questions, because anyone can recite what useMemo does. I give a small, realistic component, maybe a list with filtering and a fetch, that has a race condition and a needless re-render, and ask them to review it as if it were a teammate's pull request. Strong seniors find the bug that matters first, explain why it happens in terms of how React renders, and suggest a fix that fits the codebase rather than the cleverest one. Then I push on decisions: where would state live if this screen grew, when would you not memoise, what would you measure before optimising. The signals I listen for are trade-offs named without prompting, comfort saying they'd check something, and stories with real mistakes in them. I score against a written rubric agreed with the other interviewers, so the decision isn't just gut feel.”
Hiring on trivia recall, or on whether the candidate solves it the way you would.
Replace piece by piece: new features in React from day one, old screens moved route by route or as islands inside old pages.
Bridge: mount React roots inside the old app, share data and events through a small adapter, and share design tokens so both sides look the same.
Finish: block new code in the old framework, track what's left, and set a date when both runtimes stop shipping together.
“I'd never do a big rewrite in a branch. I'd run the two side by side and move piece by piece. First, a rule: every new feature is built in React. Then the bridge. React can mount into any DOM node with createRoot, so I can drop React islands into old pages, and whole routes move over once most of a screen is React. The two sides share data through a small adapter, like a store both can read or a few agreed events, and share design tokens so users don't see two styles. I'd order screens by value and risk, move the ones being changed anyway first, and tackle one of the ugliest early to find the real problems. A CI rule blocks new code in the old framework. And I'd track what's left and set a date to remove the old runtime, because shipping two frameworks costs bundle size every month it drags on.”
Proposing a full rewrite with a feature freeze, or a migration with no end date.
ClapAssist is an AI interview assistant for Mac and Windows. It listens to the interview on your computer and shows you what to say, in short lines you can read while you talk. Your live interview audio and screen are never stored. Your resume and notes are saved to your account so the app fills them in on any computer. It stays out of screen share on every plan, including Free; only you can see it.