React interviews for three years of experience (two to four years) ask less about definitions and more about what happened in your code: a form that kept the last user's values, a chat that scrolled one message short, a double order from a double click, a crash that only showed up in the production build. It is written for React developers with about two to four years of real work, the stage where you own screens and features end to end but someone else still designs the whole system. Each question shows what the interviewer is checking, the shape of a good answer and a short spoken answer. Swap the stories for your own before the interview.
Search all questions by round, difficulty and level, or save the ones you want to practice.
Cause: setMessages only schedules a render, so on the next line the DOM still holds the old list and the last item is the previous message.
Fix one: scroll from an effect that runs when the messages change, after React has updated the DOM.
Fix two: wrap the update in flushSync when the scroll must happen right there in the handler, and use it sparingly.
“Our send handler added the message with setMessages and on the very next line scrolled the list's last child into view. But setting state doesn't touch the DOM immediately. It asks React to render, and that happens after the handler finishes. So at the moment we scrolled, the new message wasn't in the DOM yet, and the last child was the previous message. That's why it always stopped one short. I moved the scrolling into an effect that runs when the message count changes, with a ref on an empty div at the bottom of the list, so it runs after React has put the new message on the page. That also covered incoming messages, which we wanted anyway. React also has flushSync, which applies the update right away so the DOM is current on the next line, but it forces an extra synchronous render, so I keep it for rare cases.”
const bottomRef = useRef(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ block: 'end' });
}, [messages.length]);
// at the end of the message list
<div ref={bottomRef} />
Wrapping the scroll in a setTimeout until it seems to work, without knowing why the DOM was behind.
Cause: the form stays at the same place in the tree with the same type, so React keeps its state even though the user prop changed.
Fix: give the form a key of the user's id, so a new user means a fresh component with fresh state.
Avoid: an effect that copies the new user into state, which renders stale values first and is easy to get wrong.
“The edit form kept its fields in local state, started from the user prop. When you clicked another user, the same EditForm component stayed in the same spot in the tree, so React treated it as the same instance and kept its state. The new user prop arrived, but useState had already used its initial value. My first idea was an effect that reset the fields when the id changed, but that renders one frame of old data and gets messy with more fields. The cleaner fix was putting key equal to the user's id on the form. When the id changes, React unmounts the old form and mounts a new one, so every field starts from the new user. It also reset the validation errors and the dirty flag for free.”
Adding a useEffect that calls five setters when the id changes, and never mentioning key as a reset.
Cause: the argument to useState is the initial value only; later renders ignore it.
Best fix: don't copy the prop at all; read it directly or compute what you need during render.
When a copy is real: if the user edits a draft, name the prop initialPrice so everyone can see it only seeds the draft.
“The component copied props.price into state so it could format it, and the price came from a parent that refreshed every minute. useState only reads its argument on the first render, so the state kept the first price forever while the prop moved on. There was no reason for state there at all, because nothing inside the component changed the price. I deleted the state and formatted props.price directly during render, so it's always in sync. Where a copy really was needed, like an edit box where the user types a new price, I renamed the prop to initialPrice to make it clear it's only a starting point. I try not to keep two copies of one value.”
Adding a useEffect that sets state whenever the prop changes and calling it done, without asking why the state exists.
Problem: separate flags like isLoading, isError and isSuccess can drift into combinations that should never happen.
Fix: one status value with named cases, and a reducer that moves between them on clear actions.
Bonus: transitions live in one pure function you can unit test without rendering.
“The checkout had separate state for loading, error, success, the order id, the error message and a couple more. Each handler set some of them, and one path set success without clearing the old error, so both banners showed. I replaced the flags with a single status that could be idle, submitting, succeeded or failed, and moved the updates into useReducer. Actions like submit, succeeded and failed each return a complete new state, so an error and a success can't exist together any more. The reducer is a plain function, so I wrote a few quick unit tests for the transitions without rendering anything. The component got shorter too, because the handlers just dispatch an action. I don't reach for a reducer for every form, only when several values change as one.”
function checkoutReducer(state, action) {
switch (action.type) {
case 'submit':
return { status: 'submitting' };
case 'succeeded':
return { status: 'succeeded', orderId: action.orderId };
case 'failed':
return { status: 'failed', message: action.message };
default:
return state;
}
}
const [state, dispatch] = useReducer(checkoutReducer, { status: 'idle' });
Fixing the one path that forgot to clear the error and leaving seven flags that can still disagree.
Move it: filters, sort and page go into the URL search params instead of component state.
Read and write: parse the params into typed values with defaults, and update them through the router.
Details: replace the history entry while typing so Back still works, and reset the page number when a filter changes.
“The filters lived in useState, so a refresh wiped them and a link always opened the default view. I moved status, date range, sort and page into the URL search params using the router's search params hook. The component reads the params, turns them into proper values with defaults, and the fetch depends on those values, so a shared link loads exactly the same view. When a filter changes I write it back to the URL and reset the page to one, because page five of a new filter often doesn't exist. For the search box I debounced the update and replaced the history entry instead of pushing one, otherwise every letter became a Back step. I also left defaults out of the URL so links stayed short and readable.”
Saving the filters in localStorage only, which survives a refresh but still can't be shared.
Why twice: in development, Strict Mode mounts, unmounts and mounts again to show effects that don't clean up.
Real bug: the effect opened a socket and never closed it, so the first one stayed alive.
Fix: return a cleanup that closes the socket and removes listeners; don't switch Strict Mode off.
“Strict Mode in development deliberately runs each effect, then its cleanup, then the effect again, as if the component mounted twice. Our effect opened a WebSocket and added a message listener but returned nothing, so the first connection never closed and both pushed messages into state. Production only runs it once, which hid the bug, but it would have shown up there too when a user left the chat and came back, or when the room id changed. The fix was returning a cleanup that removes the listener and closes the socket. After that, development showed one connect, one close, one connect, and a single live connection. A teammate had suggested turning Strict Mode off, but that just hides leaks like this one.”
Removing Strict Mode or adding a ref flag to skip the second run, without adding the missing cleanup.
Cause: useEffect usually runs after the browser has painted, so the first position shows before the corrected one.
Fix: measure and set the position in useLayoutEffect, which runs after the DOM update but before paint.
Care: it blocks painting, so keep the work small, and it doesn't run during server rendering.
“The tooltip rendered at a default spot, then an effect measured it with getBoundingClientRect and moved it if it went off screen. Because useEffect usually runs after the browser has painted, users saw one frame in the wrong place and then a jump. I moved the measuring and the position update into useLayoutEffect. That runs after React has updated the DOM but before the browser paints, and a state update inside it is handled before the paint too, so the user only ever sees the final position. I kept it to that one measurement, because anything slow in a layout effect delays the whole frame. It's the only place in that app we used it, and on the server-rendered pages the tooltip only opens after a hover, so the server never needed to run it.”
Hiding the flicker with a setTimeout or a CSS fade, without knowing why the first paint was wrong.
Old way: state starts from a guess, then an effect subscribes and corrects it, so the first render can be wrong.
New way: pass a subscribe function and a getSnapshot function; React reads the current value during render and re-renders when it changes.
Details: getSnapshot must return the same value when nothing changed, and an optional third function gives the value during server rendering.
“My version started with useState set to true, then an effect added online and offline listeners and updated the state. It worked, but the first render used a guess instead of the real value, and it's exactly the case React has a hook for. With useSyncExternalStore I pass a subscribe function that adds both listeners and returns a function that removes them, and a getSnapshot function that returns navigator.onLine. React reads the real value during render and keeps every component showing the same value, even during concurrent rendering. It resubscribes whenever it gets a new subscribe function, so I define that outside the component. For server rendering I pass a third function that returns true. The one trap is getSnapshot: it must return the same value if nothing changed, so building a new object on every call causes endless re-renders.”
function subscribe(callback) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
export function useOnlineStatus() {
return useSyncExternalStore(
subscribe,
() => navigator.onLine,
() => true
);
}
Saying the effect version is exactly the same, or passing a new subscribe function on every render without knowing it resubscribes each time.
Cause: the list's cached result was still treated as fresh, so going back showed the cache without refetching.
Fix: after the save succeeds, invalidate the list's cache key so it refetches, or update the cached item directly.
Keys: consistent cache keys per resource make invalidation simple and reliable.
“We used TanStack Query, and the list query had a stale time set so it wouldn't refetch on every visit. The save went through a mutation, but nothing told the cache that the list was now out of date, so going back showed the cached copy. I added an onSuccess to the mutation that invalidates the orders list key and sets the updated order into the detail key. Invalidation marks the list stale, and because it was on screen or about to be, it refetched right away. I also cleaned up our keys so every orders query started with the same prefix, which let one call invalidate all the filtered versions of the list. For small edits where the server returns the updated item, updating the cache directly saved a network round trip.”
Setting stale time to zero everywhere or forcing a page reload after every save.
Cause: nothing stopped a second click or a second Enter while the first request was still in flight.
Client: keep a submitting state, disable the button and show progress, and ignore submits while it's true.
Server: send an idempotency key made once per checkout attempt, so a repeated request returns the first order instead of creating another.
“The handler awaited the request, but nothing changed on screen until it came back, so impatient users clicked again, and a slow network made the gap long enough to place two orders. On the client I added a submitting state, set before the request and cleared in finally, disabled the button while it was true and changed its label to show progress. I also made the form's submit handler return early while submitting, because pressing Enter in a field can submit too. But the client can't be the only guard, since a retry or a flaky connection can still repeat a request. So we generated an idempotency key when the checkout page opened and sent it with the order, and the backend returned the existing order if it saw the same key again. Both halves together closed it.”
Only disabling the button and assuming the problem is gone, with nothing on the server side.
Decode: the message links to the full error text on the React website; read that first.
Map: use source maps, in the error tracker or the browser, to turn the minified stack into your files and lines.
Reproduce: build and serve the production bundle locally against the same data, since development and production can behave differently.
“The production build strips React's long error messages to keep the bundle small, so you get a number and a link. The link opens the full message, and ours said objects are not valid as a React child. That told me some component was rendering an object instead of text. Our error tracker had source maps uploaded, so the stack pointed at a price label in the order summary. I built the app for production, served it locally against the same environment, and it crashed the same way. The cause was data, not the build: one production record had the price as an object with an amount and a currency, while our test data always had a plain number. I fixed the rendering, added a guard and a test with that shape, and raised the mismatch with the backend team.”
Saying you can't debug it until it happens in development, or ignoring the link in the error message.
Pick: use the element picker to select the wrong price and land on its component.
Trace: read its props, then walk up the owners list to see who rendered it and where the value changes.
Confirm: edit a prop or state value live, and jump to the component's source to set a breakpoint.
“I open the Components tab and use the picker to click the wrong price, which selects the component that rendered it. On the right I can see its props, state and hooks. If the price is already wrong in its props, I look at the rendered-by list, which shows the chain of owners, and click up it until I find the component where the value first goes wrong. Last time that was a parent that passed the price before a discount was applied, instead of after. I can edit props and state live to check a theory without changing code, and jump to the source file to drop a breakpoint in the function that computes the value. The search box helps when the tree is huge. It's usually faster than adding logs to five files.”
Only adding console.log calls everywhere, or not knowing DevTools can show props and state at all.
Problem: mocking the client module ties each test to how the code fetches, not to what the user sees.
Fix: intercept requests at the network level with shared handlers for the happy path, and render the real component with its real hooks.
Per test: override one handler for an error, empty or slow response, and reset the handlers after each test.
“Every test mocked our api module function by function, so when we moved a screen to a data-fetching library or renamed a function, dozens of tests failed even though the screen worked. I switched us to Mock Service Worker, which intercepts the actual HTTP requests in tests. We kept a shared set of handlers returning realistic data for the common endpoints, started the mock server before the tests and reset the handlers after each one. Now a test renders the real component with the real hooks and the real client, and only the network is fake. For an error case, the test overrides one handler to return a 500, then checks the error message and the retry button. The next refactor of our data layer changed no tests at all, which was the whole point.”
Only asserting that a mocked function was called with certain arguments, and never checking what the user sees.
Render it: use renderHook to run the hook inside a tiny test component.
Act: call its functions inside act so state updates are applied before you assert.
Edges: test the limits and what happens when the inputs change, using rerender with new props.
“The hook took a page count and returned the current page plus next and prev functions that stop at the ends. I tested it with renderHook from Testing Library, which renders the hook inside a small test component and gives me result.current. I call next inside act, so React applies the update before I check the value. The useful tests were the edges: going past the last page stays on the last page, prev on page one stays on one. I also used rerender with a smaller page count to check the current page clamps down when the data shrinks, which was a real bug in the old table. The table itself then only needed one test showing the buttons are wired up, instead of every edge case through clicks.”
import { renderHook, act } from '@testing-library/react';
import { usePagination } from './usePagination';
test('stops at the last page and clamps when data shrinks', () => {
const { result, rerender } = renderHook(
({ pageCount }) => usePagination({ pageCount }),
{ initialProps: { pageCount: 3 } }
);
act(() => result.current.next());
act(() => result.current.next());
act(() => result.current.next());
expect(result.current.page).toBe(3);
rerender({ pageCount: 2 });
expect(result.current.page).toBe(2);
});
Testing internal details like how many times setState was called, instead of what the hook returns.
Extend native props: start from the built-in button props so onClick, aria attributes and the rest just work.
Own props: add a union type for variant and a boolean for loading.
Safe defaults: default type to button, and disable the button while loading.
“I start from React.ComponentPropsWithoutRef for a button, so every normal attribute is typed and allowed, and intersect it with my own props: a variant limited to a small union and an optional loading flag. In the component I pull out my props and spread the rest onto the real button. Two defaults matter. Type defaults to button, because a plain button inside a form submits it, which caused a real bug for us once. And while loading, the button is disabled and marked busy, so a second click can't send the request again. The union type means someone typing variant equals danger gets an error in the editor instead of a silently unstyled button. If the team needs refs, I'd add ref support too.”
type ButtonProps = React.ComponentPropsWithoutRef<'button'> & {
variant?: 'primary' | 'secondary';
loading?: boolean;
};
export function Button({
variant = 'primary',
loading = false,
type = 'button',
disabled,
children,
...rest
}: ButtonProps) {
return (
<button {...rest} type={type} data-variant={variant}
disabled={disabled || loading} aria-busy={loading}>
{children}
</button>
);
}
Typing props as any, or re-declaring onClick, disabled and every other attribute by hand.
Cause: the card clipped overflow and had a transform, which traps a fixed-position child inside it and creates its own stacking context, so z-index couldn't escape.
Fix: render it through createPortal into document.body, or use the native dialog element with showModal.
Behaviour: close on Escape, move focus in and keep it there, and return focus to the button that opened it.
“The modal was rendered inside a card that had overflow hidden and a transform. A transform makes the card the box that a fixed-position child is placed in, so the overlay was sized to the card and clipped by it, and it also creates a new stacking context, so no z-index could lift it above the rest of the page. I moved the modal into a portal with createPortal targeting document.body, which puts it at the top of the DOM while it stays in the same place in the React tree, so context and events still flow from its React parents. Then I fixed what a modal needs: Escape and the close button close it, focus moves into it on open and stays inside while tabbing, the page behind stops scrolling, and focus returns to the button that opened it. On a later project we used the native dialog element with showModal, which handles the top layer and Escape for you.”
Raising the z-index to a huge number, which can't escape a stacking context, and ignoring keyboard users.
Props: openId for controlled use, defaultOpenId for uncontrolled use, and an onOpenChange callback that is always called.
Decide: if openId is passed, the parent owns the state; otherwise the component keeps its own.
Keep callers safe: screens that pass nothing keep working, and one component shouldn't switch modes during its life.
“I followed the same pattern as a native input with value and defaultValue. The Accordion takes an optional openId, a defaultOpenId and an onOpenChange callback. Inside, it keeps its own state seeded from defaultOpenId, and works out whether it's controlled by checking if openId was passed at all. When it's controlled, it shows the parent's value and only calls onOpenChange, and the parent decides whether to update. When it's uncontrolled, it updates its own state and still calls onOpenChange so a parent can listen. The old screens passed nothing, so they kept working unchanged, and the new screen keeps openId in its own state and sets it when the link is clicked. I also avoid switching between the two modes during one component's life, the same thing React warns about for inputs.”
function Accordion({ openId, defaultOpenId = null, onOpenChange, items }) {
const [innerId, setInnerId] = useState(defaultOpenId);
const isControlled = openId !== undefined;
const currentId = isControlled ? openId : innerId;
function toggle(id) {
const next = currentId === id ? null : id;
if (!isControlled) setInnerId(next);
onOpenChange?.(next);
}
return items.map((item) => (
<section key={item.id}>
<button aria-expanded={currentId === item.id} onClick={() => toggle(item.id)}>
{item.title}
</button>
{currentId === item.id && <div>{item.content}</div>}
</section>
));
}
Copying the openId prop into state with an effect, so the parent and the component fight over which value is right.
Cause: the formatted value differs from what the user just typed, so React writes a new value to the input and the browser moves the caret to the end.
Fix: work out where the caret should land, then restore it with setSelectionRange after the update.
Or simpler: format on blur, or use a well-tested input mask instead of hand-rolled formatting.
“In a controlled input, when the state value you render is different from what's currently in the DOM, React writes the new value into the input, and the browser puts the caret at the end when a script replaces the value. Our onChange stripped non-digits and re-added the spaces and dash, so the formatted string almost never matched what was typed, and editing in the middle threw the cursor to the end. I fixed it by counting how many digits sat before the caret before formatting, then after the update finding the position after that many digits in the new string and calling setSelectionRange through a ref in a layout effect. For another field we just formatted on blur, which was far simpler and nobody minded. I'd also test paste and delete, because those break naive formatting.”
Switching the input to uncontrolled and formatting nothing, or blaming the browser without knowing why the caret moves.
Split: break the design into boxes by responsibility and reuse, not just by how it looks.
State: find the lowest common owner for each piece of state, and separate server data from UI state.
Check: mention what you'd change now and how you confirmed it with design and the backend.
“The last one was a subscription settings screen with a plan summary, a payment method card, an invoices table and a cancel flow. I sketched boxes over the design first: a page component that loads data, then PlanSummary, PaymentMethod, InvoiceTable and a CancelDialog, and I reused our existing card and table components instead of new ones. For state, server data like the plan and invoices went through our data-fetching hooks, not useState. The invoice table's sort and page only mattered to the table, so they stayed there. The cancel dialog's open state lived in the page, because both the summary and a banner could open it. I checked the API shape with the backend before building, which saved a rewrite. Looking back, I'd put the table's page in the URL.”
Describing only the look of the screen, with no reasons for where state lives or how the pieces split.
The comment: quote the actual feedback and the code it was about.
Your reaction: say honestly whether you agreed at first, and what convinced you.
The change: name the habit you kept and a later place it helped.
“Early on I built a product card that took eleven props, including flags like showBadge, compact and hidePrice, and a senior reviewer wrote that every new screen was going to add another flag. I was a bit defensive at first because it worked, but he showed me the if statements piling up inside it. He suggested composition: a simple Card with slots, passing the badge or the price in as children from each screen. I rewrote it that way, and the next two screens needed no changes to the card at all. Since then, when I catch myself adding a boolean prop that only one caller uses, I stop and ask whether the caller should pass the piece in instead. I pass the same comment on in my own reviews now.”
Saying you've never had useful feedback, or giving a vague answer like learning to write cleaner code.
The miss: the feature, your estimate and how long it really took.
The causes: the specific things you left out, like states, edge cases or waiting on others.
The change: how you break work down and flag risk now, with a later example.
“I estimated three days for a file upload widget with drag and drop and a progress bar. It took over two weeks. I'd pictured the happy path, and missed large files needing chunked upload, retry after a dropped connection, cancel, duplicate names, the error and empty states, keyboard access for the drop zone, and tests for all of it. I also waited on a backend change I hadn't asked for early enough. I told my lead as soon as the gaps were clear, not at the deadline, and we cut retry to a later release. Now I list every state a screen can be in, loading, empty, error, partial and success, before I give a number, and I ask about backend needs on day one. My estimates are closer since.”
Blaming the design or the backend team entirely, with nothing you'd do differently.
Size it: check how different the copies really are and how risky merging them would be.
Decide: ship the change safely, merging only if it fits the deadline with tests, otherwise update all three.
Follow up: write a ticket for the merge and tell your lead about the duplication.
“First I'd diff the three copies to see how different they really are, and check which screens use each one. If the differences are small and there are tests around the form, merging them into one component with a couple of props might fit this week and would make the field a one-place change. If they've drifted a lot or have no tests, merging under a deadline is how things break, so I'd add the field to all three, carefully, and test each screen. Either way I'd tell my lead what I found and write a ticket for merging them, with the list of differences, so it's planned rather than forgotten. Next time someone touches that form, the cleanup is ready to pick up.”
Refactoring all three silently a day before the deadline, or adding the field to one copy and missing the other two.
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.