React interviews for experienced candidates with around five years rarely ask what a hook is; they ask why you picked a pattern, what it cost you, and how you proved a fix worked. Expect stories about memory leaks, slow interactions, upgrades, shared components, code review and pushing back on a risky plan. It is written for React developers with roughly five to seven years behind them, who own a front-end module or a whole app, choose how state and data flow through it, get the call when a release breaks, and review other people's pull requests. Each answer below is a first-person story or a decision you can defend. Swap in your own project details.
Search all questions by round, difficulty and level, or save the ones you want to practice.
Sort by kind: server data, URL state, form state, local UI state and the small bit that is truly global.
Home for each: a data cache for server data, the URL for filters, local state by default, a store only for the rest.
Regret: one honest thing you'd change and what it cost the team.
“On our admin app I split state by kind. Anything from the API went into a query cache, filters and page numbers went into the URL so links could be shared, form fields stayed inside the form, and only the signed-in user and a few settings lived in a global store. That kept the store tiny and made most components easy to test. What aged badly was the store itself. We let it grow a slice for every modal and wizard just in case, so a half-finished wizard would reappear on the next visit and nobody knew who owned those slices. I moved them back into local state and reset them on unmount, and those odd bugs stopped. The trade-off is another library for new joiners to learn, so I wrote a one-page guide on which state goes where.”
Saying everything goes in one global store because it is simpler, with no thought for server data or the URL.
Why: server data needs caching, deduping, refetch and invalidation, which a plain store makes you hand-write.
Gain: less boilerplate, shared requests, background refresh.
New problems: choosing stale times, invalidating the right keys, and query keys drifting between teams.
“Our store had a slice per API resource, each with its own loading, error and last-fetched fields, and three screens could fire the same request at once. Moving that data into a query cache gave us one request per key, background refresh, and retries for free, and it deleted a lot of reducer code. The new problems were real, though. Some screens showed old data because the default stale time didn't suit them, and after a save we sometimes forgot to invalidate a related list. I fixed that by keeping all query keys in one small module with helper functions, so a mutation could invalidate every list that shows the same record. I also set stale times per resource instead of one global number.”
Claiming a cache library makes staleness and invalidation go away on its own.
Spot it: the network panel shows requests starting one after another, each waiting for a render.
Fix: start independent requests together, at the route level or in a loader, before the tree renders.
Trade-off: components own less of their own data, so be clear where data is loaded.
“On our order page, the order loaded first, then the items component mounted and fetched items, then each item fetched its stock. The network panel showed a staircase of requests, so the page took about three round trips longer than it needed to. The items request only needed the order id, which we already had from the URL. I moved loading to the route level, so the order and items requests start together as soon as the route matches, and with the backend team I added a stock endpoint that takes the order id, so one call replaced one per item and it starts at the same time too. Load time dropped a lot on slow networks. The trade-off is that the item component no longer fetches its own data, so I documented that route loaders own fetching on that page.”
Adding more loading spinners instead of changing when the requests start.
Plan: allow JS and TS side by side, convert file by file, new files in TypeScript only.
Order: shared types for API data and shared components first, where one type protects many screens.
Tighten: turn on stricter checks step by step and track the count of loose types going down.
“Our app had a few hundred JavaScript files and we couldn't pause features, so I set the compiler to allow both languages and made one rule: new files are TypeScript, and any file you touch in a real change gets converted. I started with the API layer and the shared components, because typing an API response once protected every screen that used it, and typed props on shared components caught misuse across teams. Strict null checks came later, one folder at a time, because turning them on for everything at once produced thousands of errors. I tracked the number of any types each week so we could see progress. It took most of a year of background work. The cost was slower reviews early on, and the payoff was a clear drop in undefined crashes.”
Proposing a big-bang conversion that freezes features, or turning strict mode on everywhere on day one.
Prepare: read the upgrade notes, fix old warnings and bump libraries that pin the old version first.
What changed: for React 18, createRoot, automatic batching everywhere and Strict Mode running effects twice in development.
Roll out: one step at a time behind a flag or on a small share of traffic, watching errors.
“I led our move to React 18. Before touching React, I cleared old lifecycle warnings and upgraded the libraries that didn't support it yet. Then I switched to createRoot and turned on Strict Mode. In development, Strict Mode mounts, unmounts and mounts again, and that exposed effects with no cleanup, like a store subscription that never unsubscribed and a timer that was never cleared. Those were real bugs, not noise. Automatic batching also changed one screen that read the DOM between two state updates inside a timeout, so I fixed that code instead of opting out. The TypeScript types dropped implicit children from function components, which touched a lot of files but was mechanical. We shipped it to internal users for a week before everyone. The cost was about two sprints, mostly on third-party libraries.”
Turning off Strict Mode to make the double effects go away instead of fixing the missing cleanups.
Flag: build the new screen next to the old one behind a feature flag.
Gradual: internal users first, then a small share of traffic, watching errors and key actions.
Way back: a kill switch, and delete the old screen only after it's been stable a while.
“When we rebuilt our main inbox screen, I kept the old one in the codebase and put the new one behind a feature flag checked at the route. Our own team used it for two weeks first and filed a lot of small bugs. Then we turned it on for a small slice of users and compared error rates, load time and how often people completed the main action. One drop showed up: a bulk action was harder to find, so we fixed the layout before going wider. We ramped up over a few weeks, and the flag was a one-click way back if things went wrong. The cost was keeping two versions alive for a month, so I set a date to delete the old one and stuck to it.”
Replacing the screen for everyone in one release with no flag and no way to switch back quickly.
Reproduce: a repeatable action, such as switching tabs fifty times, while watching memory.
Measure: heap snapshots before and after, compared to see what keeps growing.
Cause and fix: listeners, sockets or chart instances never cleaned up, or an unbounded cache.
“Traders kept our dashboard open for a full day, and by afternoon it was sluggish. I reproduced it by switching between two views about fifty times and watching memory climb without coming back down. I took a heap snapshot before and after and compared them. There were thousands of detached chart elements still held in memory. Our chart wrapper created a chart instance in an effect but never destroyed it in the cleanup, and it also added a window resize listener that was never removed. Adding a proper cleanup fixed most of it. I also found a client-side cache of price ticks with no size limit, so I capped it. Memory then stayed flat over a full day of switching, which I checked with a long automated run.”
Saying you'd just ask users to refresh the page, or guessing at causes without ever taking a heap snapshot.
Catch: error boundaries per screen area report render errors; global handlers catch async and event errors.
Readable: upload source maps and tag each report with the release version.
Useful: alert on new errors or spikes after a deploy, not on every single event.
“I put error boundaries around each main area, so a broken widget shows a small fallback instead of taking down the page, and each boundary reports the error with the component stack. Boundaries don't see errors in event handlers or rejected promises, so I also hooked the global error and unhandled rejection events. We upload source maps on each build but don't serve them publicly, and every report carries the release version, so after a deploy we can see at once whether a new error came with it. Alerts go to our channel only for new error types or a sudden spike, because an alert on every event got muted within a week. The first real catch was a crash on one old browser, found hours before support heard about it.”
Thinking one error boundary at the root catches everything, including async and event-handler errors.
Cause: the package bundled or installed its own copy of React, so its hooks talk to a different React.
Check: list installed React versions and look inside the package's build output.
Fix: make React a peer dependency, don't bundle it, and dedupe in the app.
“This one bit us when we split our design system into its own package. Every hook inside it threw the invalid hook call error, even though the code looked fine. Running a list of installed packages showed two copies of React: the app's, and one the design system had installed because React was listed as a normal dependency. Hooks rely on the same React instance that renders the component, so two copies breaks them. I moved React to a peer dependency in the package, marked it as external in the library build so it wasn't bundled, and added a check in CI that fails if more than one React is installed. The trade-off is that apps must provide a compatible React version, which we documented.”
Rewriting the components as classes to avoid hooks instead of finding the duplicate React.
Server by default: data fetching and static, heavy markup stay on the server and ship no component code.
Client where needed: anything with state, effects, event handlers or browser APIs, marked with 'use client'.
Boundary rules: props crossing it must be serialisable, and server parts pass into client parts as children.
“On our product pages I kept the data loading and the long description rendering as server components, so that code never shipped to the browser. The image gallery, the add-to-cart button and the reviews filter needed state or click handlers, so they became small client components, and I pushed the 'use client' line as far down the tree as I could, because everything a client component imports becomes client code too. What caught us out first was passing a click handler and an instance of one of our own classes as props into a client component; props crossing that boundary have to be serialisable. The second was a client layout that imported a server component. That quietly turns it into client code, and it broke because it read from the database, so we passed it in as children instead. The first-load bundle shrank noticeably on those pages.”
Marking every file 'use client' to make errors go away, or thinking server components are just another name for server-side rendering.
Composition: children, slots or compound parts instead of a flag for every variation.
Control: work uncontrolled by default, but accept a value and onChange when a team needs control.
Escape hatches: forward refs, pass through extra props, and keep accessibility built in.
“Our shared table started as one component with props like showFooter, stickyHeader and hideSortOnMobile, and every team asked for another flag. When I redesigned it, I made it compound: Table, Table.Header, Table.Row and so on, so teams arrange the pieces instead of toggling flags. Sorting and selection work on their own by default, but a team can pass the sort value and a change handler when they need to keep it in the URL. I forwarded refs, passed extra props through to the real elements, and kept keyboard support inside so nobody could break it. The trade-off is that composition is more typing for simple cases, so I also shipped a small preset for the common list page. New requests dropped a lot after that.”
Adding one more boolean prop for every request until the component can't be understood or tested.
Strings: move text into message files with placeholders and plural rules, never glue sentences together.
Formatting: dates, numbers and lists through the built-in Intl APIs with the user's locale.
Layout: set dir on the page and use logical CSS properties so right-to-left mirrors itself.
“Our app had English text hard-coded everywhere, including sentences built by joining pieces, which falls apart in languages with a different word order. I moved every string into message files with named placeholders and proper plural rules, because some languages have more than two plural forms. Dates and numbers went through the Intl APIs with the user's locale instead of our own helpers. Each language's messages load lazily, so users only download their own. For Arabic, I set the dir attribute on the html element and swapped left and right margins for logical properties like margin-inline-start, so most of the layout mirrored without extra CSS. A few icons, like back arrows, had to be flipped by hand. The biggest cost was the one-time string extraction, which we split by screen over a few sprints.”
Building sentences by joining translated fragments, or treating right-to-left as just aligning text to the right.
Diagnose: one big state object updated on every keystroke re-rendered the whole form.
Redesign: field-level subscriptions or uncontrolled inputs, and one validation schema per step.
Flow: save each step as it's completed, and validate on blur or step change, not every key.
“Our onboarding form had forty fields over five steps, all in one useState object at the top, with validation running on every keystroke. Typing lagged on older laptops, and step two sometimes lost data when people went back. I moved it to a form library that keeps inputs uncontrolled and only re-renders the field that changed, and I wrote one validation schema per step that runs on blur and when moving on. Each finished step is saved to a draft on the server, so going back just reads the draft and a refresh doesn't lose work. Typing became smooth and the lost-data bugs stopped. The trade-off was learning a new library and rewriting some custom inputs to register with it.”
Adding debounce to every input to hide the lag without fixing why the whole form re-renders.
Find it: field data to learn which interactions are slow, then a performance trace to see the long task.
Common causes: a big re-render after the click, heavy handler work, or third-party scripts.
Fixes: smaller updates, mark slow updates as transitions, split or defer heavy work.
“Our field data showed the worst interaction was clicking a filter chip on the search page. In a performance trace, one click caused a long task where most of the time was React re-rendering the whole results area, plus an analytics call that serialised a big object synchronously. I moved the filter state down so only the results list re-rendered, wrapped the results update in startTransition so the chip itself responded straight away, and pushed the analytics work to run after the next paint. The chip now feels instant even on slow phones. The trade-off is that for a moment the old results stay on screen, so I dimmed them to show the update was in progress. I checked the gain in field data over the next two weeks, not just on my laptop.”
Jumping straight to wrapping everything in memo without measuring which interaction is slow or why.
Why: every consumer re-renders when the value changes, and memo on the child doesn't stop it.
Split: separate contexts for things that change at different speeds, and state from actions.
Stabilise: memoise the value object; use a store with selectors if one value changes very often.
“We had one AppContext with user, theme and cart together, and adding an item to the cart re-rendered the header, the sidebar and every product card. The context value changed on every cart update, and every component reading that context re-rendered, whatever part it used. I split it into three contexts, and for the cart I also split state from actions, so buttons that only add items read the actions context, which never changes. I memoised the value objects so a parent re-render didn't create new ones. Adding to the cart then touched only the cart badge and the cart page. The trade-off is more providers to wire up, so I wrapped them in one AppProviders component.”
const CartItemsContext = createContext([]);
const CartActionsContext = createContext(null);
function CartProvider({ children }) {
const [items, setItems] = useState([]);
const actions = useMemo(() => ({
add: (item) => setItems((xs) => [...xs, item]),
remove: (id) => setItems((xs) => xs.filter((x) => x.id !== id)),
}), []);
return (
<CartActionsContext.Provider value={actions}>
<CartItemsContext.Provider value={items}>{children}</CartItemsContext.Provider>
</CartActionsContext.Provider>
);
}
Believing React.memo on the child components will stop re-renders caused by a context change.
Find: a bundle analyser to see which packages and files take the space.
Common culprits: a whole library imported for one function, locale data, barrel files, heavy code on the first route.
Guard: a size budget checked on every pull request.
“Our first-load bundle had roughly doubled in six months and the landing page felt slow on mobile. I ran a bundle analyser and found three things. A date library was pulling in every locale, one icon import went through a barrel file that dragged in the whole icon set, and a rich-text editor used on one admin page was sitting in the main chunk. I switched to importing only the locales we used, imported icons one by one, and lazy-loaded the editor route. The main bundle came down to roughly where it had started. Then I added a size check to CI that comments on the pull request and fails it past a budget, so the next growth gets a conversation instead of a surprise.”
Fixing the size once with no budget or check, so it creeps back within a few months.
Buffer: collect incoming messages outside React state, for example in a ref or a small store.
Flush: apply them in one state update per animation frame or on a short timer.
Narrow: let each row subscribe to its own data, and virtualise long lists.
“Our trading screen called setState for every message, so at peak we asked React to render hundreds of times a second and the whole page stuttered. I changed it so messages go into a buffer held in a ref, keyed by instrument so a newer price simply replaces an older one. Once per animation frame I flush the buffer into state in a single update. That capped rendering at the screen's frame rate and threw away prices nobody would ever have seen. I also memoised the rows so only rows whose price changed re-render, and virtualised the list so we only draw what's on screen. The trade-off is a delay of up to one frame, which nobody can notice, and it meant the rows needed stable keys and props.”
function useBufferedPrices(socket) {
const [prices, setPrices] = useState({});
const buffer = useRef({});
useEffect(() => {
let frame = 0;
const flush = () => {
frame = 0;
const batch = buffer.current;
buffer.current = {};
setPrices((prev) => ({ ...prev, ...batch }));
};
const onMessage = (event) => {
const { symbol, price } = JSON.parse(event.data);
buffer.current[symbol] = price; // newer price replaces older
if (!frame) frame = requestAnimationFrame(flush);
};
socket.addEventListener('message', onMessage);
return () => {
socket.removeEventListener('message', onMessage);
cancelAnimationFrame(frame);
};
}, [socket]);
return prices;
}
Keeping one state update per message and hoping memo alone will make it fast.
Weight: most tests render a screen with a mocked network and act like a user.
Edges: plain unit tests for tricky logic, a few end-to-end tests for money and login paths.
Stopped: snapshot tests and tests of internal state that broke on every refactor.
“Where I landed was mostly integration-style component tests. We render a whole screen, mock the network at the request level, and click and type like a user would. Pure logic, like price rules and date handling, gets plain unit tests because they're fast and exact. On top of that we kept about a dozen end-to-end tests for sign-in, checkout and anything that touches money, running against a staging build. What I stopped was large snapshot tests, because people just updated them without reading, and tests that checked a component's internal state, which broke every time we refactored without catching real bugs. The trade-off is that integration tests are slower than unit tests, so we run them in parallel in CI.”
Quoting a coverage target as the whole strategy, with no view on which tests actually catch bugs.
Name the bug risk: synced state goes out of date and causes an extra render with the old value first.
Point the way: derive values during render, or reset with a key; one worked example, not a rewrite.
Coach: a short call, and let them make the change and own it.
“I'd start by reading it for what works, then leave one clear comment on the pattern rather than ten small ones. I'd explain that copying a prop into state means the two can disagree, and each effect adds a render where the screen briefly shows the old value. I'd show a tiny example of computing the value during render instead, and mention resetting a component with a key when the whole form should start fresh for a new record. Then I'd suggest a ten-minute call, because this is easier to explain live than in comments. I'd let them make the change so they own it. Last time I did this, the same developer caught the pattern in someone else's review a month later.”
Rewriting the code yourself and merging it, so the junior learns nothing and stops asking questions.
Understand: what problem the move is meant to solve, such as slow first load or SEO.
Evidence: a spike on one real page to measure the gain and list what breaks.
Alternative: move one route at a time, with a clear point where you stop or carry on.
“I'd first ask what we're trying to fix, because the answer shapes everything. If it's slow first load on marketing pages, that's a much smaller job than moving the logged-in app. Then I'd offer a time-boxed spike: move one real page, measure load times, and list what broke, like libraries that only work in the browser, our auth handling and our testing setup. I'd bring that back with an estimate and a proposal to migrate route by route, starting with public pages, with a checkpoint where we decide to continue or stop. I've done this once, and the spike showed the logged-in screens gained very little, so we moved only the public pages and saved the team a quarter.”
Either agreeing to a big-bang rewrite with no evidence, or refusing flatly without offering a way to test the idea.
Risk: raw user HTML can run scripts through attributes like onerror, a stored XSS hole.
Safer options: render a limited format like Markdown to React elements, or sanitise HTML with a proven library.
Defence in depth: sanitise on the server too, and add a Content Security Policy.
“I'd block that pull request, but kindly, and explain why in one comment. React escapes text by default, and dangerouslySetInnerHTML switches that off, so a comment containing an image tag with an onerror handler would run script for every reader, which could steal sessions. Then I'd offer a way forward, because the need is real. My first choice is to store comments as Markdown and render them to React elements, allowing only bold, italics and links. If we truly need HTML, run it through a well-maintained sanitiser with a short allow-list, ideally on the server when saving as well as when rendering. I'd also check links can't use javascript URLs. Last time this came up, we added a lint rule that flags any new dangerouslySetInnerHTML for a second reviewer.”
Approving it because the comments come from logged-in users, or trying to strip script tags with a regular expression.
Decision: what you chose and why it made sense with what you knew then.
Cost: the concrete pain it caused the team or users.
Change: how you fixed it or now decide differently.
“Early in a project I pushed for a CSS-in-JS library that built styles at runtime, because it made theming easy and we liked keeping styles next to components. It worked well at first. As the app grew, profiling showed style work was a real part of our render time on big lists, and moving to server rendering was harder because of how it injected styles. We spent a good part of a quarter moving to a zero-runtime approach. What I learned is to test a choice at the scale we expect in a year, not the size we are today, and to write down why we chose something so the next person can tell when those reasons no longer hold. I now write a short decision note for anything hard to undo.”
Picking a trivial mistake, or blaming the decision entirely on someone else.
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.