This page is for anyone facing a React round, from a first frontend job to a senior role. Most React interviews open with how rendering works, keys, and props versus state, then spend a long time on hooks and useEffect, because that is where real bugs live. After that come re-renders and memoisation, context versus a state library, error boundaries, lazy loading and a light look at server components, and finally how you test components. Senior rounds add a production story and a judgement call. Each question shows what the interviewer is checking, the shape of a strong answer and a short answer you can say out loud.
Search all questions by round, difficulty and level, or save the ones you want to practise.
Render: components run and return elements, plain objects that describe the UI.
Reconcile: React compares the new tree with the previous one to find what changed.
Commit: only the real DOM nodes that differ are updated.
"When state changes, React calls the component again, and it returns a new tree of elements. Those elements are just plain objects describing what the UI should look like, and that tree is what people call the virtual DOM. React then compares it with the tree from the last render. That comparison is reconciliation. It uses a couple of simple rules: if an element's type changes, say a div becomes a span, it throws that part away and builds it fresh; if the type is the same, it keeps the DOM node and just updates the props that changed; and for lists it matches children by key. Finally, in the commit phase, it applies only those differences to the real DOM. So rendering a component doesn't mean touching the DOM. It means working out what, if anything, needs to change."
Saying the virtual DOM is always faster than the real DOM, or that every render rewrites the page.
Purpose: keys tell React which item is which between renders, so it can match old and new children.
Index problem: when items are inserted, removed or sorted, the index moves to a different item and state follows the wrong row.
Good keys: a stable id from the data, unique among siblings, never generated during render.
"A key is how React tells list items apart from one render to the next. Without it, React matches children by position. If I use the index as the key and then delete the first item, every item's index shifts by one, so React thinks item two is now item one. That's mostly a wasted update, but it becomes a real bug when rows hold their own state, like a half-typed input or a checked box. The state stays attached to the index, so it jumps to the wrong row. The fix is a stable id from the data, like a database id. Keys only need to be unique among siblings, not across the whole app. And I'd never use something like Math.random in render, because then every item looks brand new every time and React remounts all of them."
// Stable id from the data, not the position
{todos.map((todo) => (
<TodoRow key={todo.id} todo={todo} />
))}
Saying keys only exist to silence a console warning, or that the index is always fine.
Props: passed in by the parent, read-only inside the child.
State: owned by the component, changed only through its setter, which triggers a re-render.
Flow: data goes down as props; changes go up through callback props.
"Props are the inputs a parent passes to a child, like arguments to a function. The child reads them but never changes them. State is data a component owns and remembers between renders, and it changes it only by calling the setter, which tells React to render again. So if a child needs to change something that came in as a prop, it can't edit the prop. The parent passes down a callback, like onChange, the child calls it, and the parent updates its own state. That new value flows back down as a prop. The rule I use to decide is simple: if a value can be worked out from props or other state, it shouldn't be state at all. And if two components need the same changing value, it belongs in whichever parent they share."
Saying a child can update its props, or treating state as a place to store anything, including values that can be derived.
Snapshot: count is a fixed value for this render; it doesn't change until the next render.
Queue: the three calls each ask for the same value, zero plus one.
Fix: pass an updater, setCount(c => c + 1), which gets the latest queued value.
"Inside one render, count is just a constant. If it's zero, all three calls say set the count to zero plus one, so React queues the same value three times and the next render shows one. Calling the setter doesn't change the variable right away. It asks React for a new render with a new value. React also batches these calls, so they produce a single re-render instead of three. The fix is the updater form: setCount(c => c + 1). React runs each updater in order against the latest pending value, so zero becomes one, then two, then three. I use the updater form whenever the new state depends on the old one, especially inside timeouts, intervals or async code, where the count variable I captured might already be out of date."
function Counter() {
const [count, setCount] = useState(0);
function addThree() {
setCount((c) => c + 1);
setCount((c) => c + 1);
setCount((c) => c + 1);
}
return <button onClick={addThree}>{count}</button>;
}
Saying setState is broken or random, or suggesting a setTimeout between the calls as the fix.
Reference check: React compares the old and new state with Object.is; the same array means no change.
Knock-on bugs: memoised children and effects keyed on that array also miss the change.
Fix: build a new array or object with spread, map or filter.
"React decides whether state changed by comparing the old and new values by reference. Push changes the array in place, so when my teammate passes that same array to the setter, React sees the identical reference and can skip the re-render. Even when something else causes a render and the new item shows up, it's fragile: memoised children and effects that depend on that array still think nothing changed. The fix is to treat state as read-only and always create a new value. To add, I spread into a new array. To update one item, I map and return a new object for the one that changed. To remove, I filter. If the data is deeply nested and the spreading gets ugly, a helper like Immer lets you write mutating-style code that produces a fresh copy."
// Wrong: same array, React may skip the update
todos.push(newTodo);
setTodos(todos);
// Right: always a new array
setTodos([...todos, newTodo]);
setTodos(todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)));
setTodos(todos.filter((t) => t.id !== id));
Fixing it with a forceUpdate or a dummy counter instead of stopping the mutation.
Lift: move the state to the nearest parent both siblings share.
Pass down: give one sibling the value and the other a callback to change it.
When it gets deep: use composition or context instead of threading props through many layers.
"I move the state up to the closest parent they both sit under. Say I have a search box and a results list. The parent holds the query in state, passes the query to the list, and passes a callback like onQueryChange to the search box. When the user types, the box calls the callback, the parent updates its state, and both children render with the new value. There's now one source of truth, so the two can never disagree. The cost is that the parent re-renders on every change, which is usually fine. If the shared parent is many levels up and I'm passing props through components that don't use them, I'd first try composition, passing the finished child down as children, and then reach for context if that's still messy."
Keeping a copy of the value in each sibling and syncing them with effects.
Controlled: React state holds the value; the input shows it and reports changes through onChange.
Uncontrolled: the DOM holds the value; you set defaultValue and read it with a ref or form data.
Choice: controlled for live validation or dependent fields; uncontrolled for simple forms and file inputs.
"In a controlled input, React owns the value. I keep it in state, pass it as value, and update state in onChange. Every keystroke goes through React, so I can validate as the user types, format the text, disable the submit button, or show another field based on the answer. In an uncontrolled input, the browser owns the value. I give it a defaultValue and read what's there when I need it, with a ref or by reading the form data on submit. That's less code and fewer renders, and it's the only option for a file input, because only the user can choose its file. I use controlled inputs when the UI reacts to what's typed, and uncontrolled for simple forms that only matter on submit. Form libraries often lean on uncontrolled inputs for speed on large forms."
// Controlled
const [email, setEmail] = useState('');
<input value={email} onChange={(e) => setEmail(e.target.value)} />
// Uncontrolled
<input name="email" defaultValue="" ref={emailRef} />
Saying uncontrolled inputs are wrong, or not knowing who holds the value in each case.
No array: runs after every render.
Empty array: runs after the first render; cleanup runs on unmount.
With values: runs after the first render and again whenever any listed value changes.
Cleanup: runs before the effect runs again and when the component unmounts.
"With no array, the effect runs after every render. With an empty array, it runs once after the component first appears, and its cleanup runs when the component goes away. With values in the array, it runs after the first render and then again whenever any of those values has changed since the last render. React checks that with Object.is, so a new object or function counts as changed every time. The cleanup is the function I return. It runs before the effect runs again, and on unmount, so it's where I clear timers, remove listeners or cancel a request. The important rule is that the array isn't a setting I choose freely. It should list every prop, state value or function from the component that the effect reads, and the lint rule helps enforce that."
Treating the array as a way to control how often the effect runs and leaving out values the effect actually uses.
Cause: the options object is created fresh each render, so the dependency always looks changed.
Loop: effect runs, sets state, component re-renders, new object, effect runs again.
Fix: depend on the primitive value, or create the object inside the effect.
"The options object is built in the body of the component, so every render makes a brand new object. React compares dependencies by reference, so to React the options always changed and the effect runs after every render. The effect fetches and calls setUser with a new object, which triggers a render, which makes a new options object, and round it goes. The fix is to depend on what really matters, which is the userId, a plain string. I build the options inside the effect, so the effect only runs again when the id changes. If an object really does have to come from outside, I'd memoise it with useMemo so it keeps the same reference. What I wouldn't do is just delete it from the array, because then the effect reads stale values and the lint rule is right to complain."
// Bug: a new object every render, so the effect never settles
const options = { id: userId };
useEffect(() => {
fetchUser(options).then(setUser);
}, [options]);
// Fix: depend on the primitive, build the object inside
useEffect(() => {
fetchUser({ id: userId }).then(setUser);
}, [userId]);
Removing the dependency or adding a boolean flag to stop the second run without explaining why the object changes.
Closure: the interval callback was created on the first render and captured count as 0.
Result: every tick sets the count to 0 plus 1, so it stays at 1.
Fix one: use the updater form, setCount(c => c + 1), so nothing stale is read.
Fix two: if you must read the latest value, keep it in a ref or list it as a dependency.
"The effect runs once, on the first render, and creates the interval callback then. That callback is a closure over the count from that render, which is zero. Later renders make new count values, but the interval is still holding the old function, so every second it says set the count to zero plus one. The count goes to one and stays there. That's a stale closure. The cleanest fix here is the updater form: setCount(c => c + 1). The callback no longer reads count at all, React hands it the latest value. Another fix is to put count in the dependency array, which works but tears down and restarts the interval on every tick. And when the callback needs to read something fresh, not just update state, I store it in a ref that I keep current, and read ref.current inside the interval."
useEffect(() => {
const id = setInterval(() => {
// setCount(count + 1) would read a stale count
setCount((c) => c + 1);
}, 1000);
return () => clearInterval(id);
}, []);
Saying setInterval is unreliable in React, or fixing it by moving the interval outside the component.
Race: two requests are in flight; the older one can finish last and overwrite the newer result.
Cleanup: when the id changes, the old effect's cleanup runs, so mark it stale or abort it.
In practice: a data-fetching library handles this, plus caching and retries.
"Each id change starts a new request, but nothing stops the old one. If I click user one and then user two, and user one's request is slower, it finishes last and calls setUser with the wrong person. The fix uses the cleanup function. When the id changes, React runs the previous effect's cleanup before starting the new one, so I set a flag there, and the old request checks the flag before it sets state. Even better, I pass an AbortController signal to fetch and abort in the cleanup, which cancels the network call too. That's also why the effect running twice in development doesn't cause trouble: the first run is cleaned up. In a real app I'd usually let a data-fetching library own this, since it handles races, caching and retries, but I want to know what it's doing underneath."
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${id}`, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(setUser)
.catch((err) => {
if (err.name !== 'AbortError') setError(err);
});
return () => controller.abort();
}, [id]);
Not seeing the race at all, or fixing it with a delay or a debounce alone.
Purpose: effects sync with external systems like the network, timers, subscriptions or a non-React widget.
Derived values: compute them during render instead of storing them and syncing with an effect.
User actions: logic that happens because of a click belongs in the event handler.
Resetting state: change the key rather than resetting state in an effect.
"An effect is for keeping a component in sync with something outside React: a subscription, a timer, a browser API, a request. The most common misuse is derived state. If I have a list and a filter, I don't store filteredItems in state and update it in an effect. I just compute it during render, and wrap it in useMemo if it's genuinely expensive. That removes an extra render and a whole class of out-of-sync bugs. The second misuse is putting event logic in an effect, like watching a submitted flag to send a request. If it happens because the user clicked, it goes in the click handler. The third is resetting a form when a prop like userId changes. Instead of an effect that clears each field, I give the component key={userId}, and React gives it fresh state."
Saying effects are the right place for any code that should run when state changes.
Why it matters: a missing dependency means the effect reads stale values sooner or later.
Find the cause: work out why the dependency changes so often.
Fix options: move the function inside the effect, use an updater, memoise the value, or depend on a primitive.
Question the effect: sometimes the code belongs in an event handler instead.
"I'd ask to look at it together, because that warning is usually right. If the effect uses a value that isn't in the list, it will read a stale copy at some point, and those bugs are painful to find later. The real question is why the dependency changes so often. Most of the time it's a function or object recreated on every render. If the function is only used by the effect, I move it inside the effect. If the effect sets state from the previous state, the updater form means it doesn't need that state as a dependency. If it's an object, I depend on the primitive fields or memoise it. And sometimes the honest answer is that the code shouldn't be an effect at all, because it responds to a click. I'd keep the disable comment only if there was a clear written reason, which is rare."
Approving the disable comment because the tests pass, or adding a ref to dodge every dependency without understanding the cause.
useMemo: caches the result of a calculation until its dependencies change.
useCallback: caches a function itself; it's useMemo returning a function.
When it helps: an expensive calculation, a prop to a memoised child, or a value used as a hook dependency.
Cost: comparing dependencies and extra code; no gain if nothing downstream checks the reference.
"useMemo runs a function and keeps its result, and only reruns it when a dependency changes. useCallback keeps the function itself, so I get the same function reference between renders. Really, useCallback is just useMemo that returns a function. Neither makes a component render less on its own. They help in three situations. First, a calculation that's actually slow, like sorting or filtering thousands of rows, which I'd confirm by measuring. Second, passing an object or function to a child wrapped in React.memo, because otherwise a new reference each render defeats the memo. Third, when the value is a dependency of another hook, like an effect, so the effect doesn't rerun for no reason. Outside those cases they only add cost and noise. React also treats the cache as an optimisation, so my code must still be correct without it."
Saying useCallback stops a function from being created, or that memoising everything is always faster.
Triggers: the component's own state changes, its parent re-renders, or a context it reads changes.
Props myth: without memo, children re-render when the parent does, even with identical props.
React.memo: skips the re-render if every prop is equal by reference; its own state and context still trigger it.
"There are three triggers. The component's own state changes, a context value it reads changes, or its parent re-renders. That last one surprises people: by default, when a parent renders, every child renders too, whether its props changed or not. Props changing isn't really a separate cause, it's a side effect of the parent rendering. React.memo changes that for one component. It compares each prop with the previous one using Object.is, and if they're all the same it skips rendering that component and its subtree. The catch is references. If the parent passes an inline object, an inline arrow function, or JSX as children, those are new every time, so memo sees a change and does nothing useful. That's where useMemo and useCallback come in. And a memoised component still re-renders when its own state or a context it uses changes."
Saying a component only re-renders when its props change.
Acknowledge: the intent is good, and memoising has real uses.
Explain the cost: it adds work, dependency lists to maintain, and harder-to-read code.
Point to where it helps: memoised children, expensive calculations, hook dependencies.
Agree a rule: measure with the profiler first, then write down a team guideline.
"I'd start by saying the goal is right, and there are places where it matters. Then I'd explain the trade-off. useMemo and useCallback aren't free: React has to store the value and compare dependencies every render, and every hook adds a dependency list someone has to keep correct. Get one wrong and you've swapped a speed problem for a stale data bug. Most of these calls protect nothing, because the child isn't wrapped in React.memo and nothing else checks the reference. I'd suggest we keep the ones that pass props to a memoised child, feed an effect, or wrap a genuinely heavy calculation, and drop the rest. Then I'd offer to pair on profiling the screen, because if there is a slowdown we want to fix the real cause. Afterwards I'd write the rule down so reviews stay consistent."
Either approving it without comment or telling the teammate memoisation is always bad.
Reproduce and profile: is the time spent filtering, rendering rows, or both?
Keep typing urgent: the input updates immediately; the list uses useDeferredValue or a transition.
Do less work: memoise the filtered result and the row component.
Render less: virtualise the list so only visible rows are in the DOM.
"First I'd reproduce it and profile a few keystrokes to see where the time goes: the filter itself, rendering thousands of rows, or both. Usually it's the rendering. My first fix is to stop the list from blocking the input. The input keeps its own state so every keystroke shows up at once, and the list reads a deferred copy of the query through useDeferredValue. React then treats updating the list as lower priority and can drop work that's already out of date while the user keeps typing. Second, I'd memoise the filtered array on the deferred query, and wrap the row component in React.memo so unchanged rows skip rendering. Third, if there are still thousands of rows on screen, I'd virtualise the list so only the rows in view exist in the DOM. If the filtering happened on the server, I'd debounce the requests too. Then I'd profile again to confirm."
Jumping straight to a debounce or to memo without measuring where the time actually goes.
What it is: a box with a current property that lives for the whole life of the component.
Difference: changing ref.current doesn't re-render; changing state does.
Uses: DOM nodes for focus or measuring, timer ids, the latest value for a callback.
"useRef gives me an object with a current property, and React hands me the same object on every render. I can change current whenever I want and it doesn't trigger a render. State is the opposite: changing it tells React to render again with the new value. So the question I ask is, does the screen need to show this value? If yes, it's state. If it's just something I need to remember behind the scenes, it's a ref. The classic use is a DOM node: I pass the ref to an input and call ref.current.focus() after a button click. Others are storing a timer id so I can clear it later, or keeping the latest value for an interval callback. One rule: I don't read or write ref.current during render, only in effects and event handlers, or the output becomes unpredictable."
function SearchBox() {
const inputRef = useRef(null);
return (
<>
<input ref={inputRef} />
<button onClick={() => inputRef.current.focus()}>Search</button>
</>
);
}
Using a ref to hold a value that's shown on screen and then wondering why the screen doesn't update.
How React tracks hooks: by the order they're called in each render.
What breaks: skip one call and every later hook reads the state meant for another.
The rule: call hooks only at the top level of a component or custom hook; put conditions inside the hook.
"React doesn't know hooks by name. For each component it keeps a list of hook slots, and on every render it hands them out in order: the first useState call gets slot one, the second gets slot two, and so on. If I put a hook inside an if, then on a render where the condition is false that call is skipped, and every hook after it gets shifted by one. Suddenly a useState reads the value that belonged to a different useState, or an effect gets someone else's dependencies. In development React usually warns or throws an error about the order or number of hooks. So hooks go at the top level of a component or custom hook, never inside conditions, loops, or nested functions. If I need conditional behaviour, I call the hook every time and put the condition inside it, like an early return inside the effect."
Saying it's just a style convention, or that React tracks hooks by variable name.
State: keep the debounced copy in state, starting at the current value.
Effect: on each change, start a timer to copy the value; clear it in the cleanup.
Reuse: each component that calls the hook gets its own state; hooks share logic, not data.
"The hook takes a value and a delay and returns a copy of the value that only updates once the user has stopped typing for that long. Inside, I keep the debounced value in state. An effect depends on the value and the delay. Each time the value changes, it starts a timeout that copies it across, and the cleanup clears that timeout. So while someone is typing quickly, every keystroke cancels the previous timer, and only the last one survives. In the component, the input stays fully responsive because it still uses the raw text, and the fetch effect depends on the debounced value instead. The name starts with use, which lets the lint rules check it. And if two components use this hook, they each get their own separate state. A custom hook shares the logic, not the data."
function useDebouncedValue(value, delay) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
// In a component
const debouncedQuery = useDebouncedValue(query, 300);
Forgetting the cleanup, so every keystroke still fires an update later.
Mechanism: a Provider supplies a value; any component below that reads it with useContext gets it without props.
Re-renders: when the value changes, every component that reads that context re-renders.
Good fit: values that change rarely, like theme, locale or the signed-in user.
Wrong fit: fast-changing data read by many components; split contexts or use a store.
"Context lets a parent make a value available to anything below it, without passing props through every layer. I create a context, wrap part of the tree in its Provider with a value, and any component inside can read it with useContext. It doesn't manage state by itself. Usually the value comes from useState in the provider component. The thing to watch is re-renders. When the provider's value changes, every component reading that context re-renders, even if it only uses one field. And if I pass a new object as the value on every render, consumers re-render every time the provider does. So I memoise the value and split contexts by how often they change. It's great for theme, language or the current user. It's the wrong tool for something like fast form input or a big shared data set read in many places."
Calling context a replacement for any state library, with no mention of the re-render cost.
Local first: most state belongs in the component that uses it, or its nearest shared parent.
Server data: cached API data is its own problem; a data-fetching library handles caching, refetching and loading states.
Shared client state: a store with selectors lets components subscribe to just the slice they use.
Decide on pain: add a library when you can name the problem it fixes.
"I start by sorting the state into kinds. Most UI state, like whether a dropdown is open, stays local. Then there's data from the server, which is really a cache: it needs loading and error states, refetching, and deduping. A lot of what used to go into global stores is really that, and a data-fetching library like TanStack Query handles it far better than hand-written context. What's left is shared client state, like a multi-step editor or a shopping cart touched from many screens. If it changes often and many components read different parts of it, context re-renders too much, and a store like Redux Toolkit or Zustand fits better, because components subscribe through selectors and only re-render when their slice changes. You also get dev tools and a clear place for update logic. I add one when I can name the pain, not up front."
Saying every app needs Redux, or that context always replaces one, without talking about server data or re-renders.
What it is: a class component that shows a fallback when something below it throws.
Catches: errors during rendering, in lifecycle methods and in constructors of its children.
Misses: event handlers, async code like timers and promises, server rendering, and its own errors.
Placement: around routes and risky widgets, so one failure doesn't blank the whole page.
"An error boundary is a component that catches errors thrown while rendering the components under it and shows a fallback instead of unmounting the whole app. It has to be a class component with getDerivedStateFromError to switch to the fallback, and usually componentDidCatch to log the error. There's no built-in hook version, so most teams use a small library or one shared class. It catches errors during render, in lifecycle methods and in constructors below it. It doesn't catch errors in event handlers, in async code like a timeout or a promise, during server rendering, or inside the boundary itself. For those I use try and catch, and set error state that the UI shows. For placement, I put one near the top as a last resort and more around routes and risky widgets, so a broken chart doesn't take down the page."
Saying an error boundary catches every error in the app, including clicks and failed requests.
lazy: takes a function that dynamically imports a module and loads that component on first render.
Suspense: shows a fallback while the code is loading.
Split points: routes, heavy modals, editors and charts that most users don't open.
Gotchas: declare lazy components at module level; watch for loading flashes.
"React.lazy lets me turn a component into its own chunk. I give it a function that calls a dynamic import, and the bundler splits that file out. The code only downloads the first time the component renders. While it's loading, the component suspends, and the nearest Suspense boundary above shows its fallback, like a spinner or a skeleton. The best split points are the ones most users never hit, or not straight away: separate routes, a settings page, a rich-text editor, a charting library, a big modal. I declare lazy components at the top level of a module, never inside another component, or they'd be recreated and lose state on every render. I also place Suspense boundaries so a small part of the page shows a fallback, not the whole screen, and I can start the import early on hover to hide the delay."
const Settings = lazy(() => import('./Settings'));
function App() {
return (
<Suspense fallback={<Spinner />}>
<Settings />
</Suspense>
);
}
Lazy-loading every small component, or defining the lazy component inside the render function.
Server components: run only on the server; their code and dependencies never ship to the browser.
Limits: no state, effects or browser APIs; interactive parts are client components marked with 'use client'.
SSR: renders client components to HTML on the server, then the same JavaScript loads and hydrates in the browser.
Together: they complement each other; a framework usually wires both up.
"With classic server-side rendering, the server runs my components to produce the first HTML, so the page shows up fast, but all that component code still ships to the browser and hydrates there to become interactive. Server components are different. They only ever run on the server, so their code and any libraries they import never reach the browser bundle. They can read data directly, like calling the database, and pass the result down. The trade-off is they can't use state, effects, or browser APIs. Anything interactive goes in a client component, marked with the 'use client' directive, and a server component can render client components and pass them props that can be serialised. So SSR is about when the first HTML appears, and server components are about where component code runs. In practice you use them through a framework that supports them."
Saying server components are just SSR with a new name, or that they can use useState.
Tools: a test runner like Jest or Vitest plus React Testing Library with a DOM environment.
Approach: render, find elements by role, label or text, act like a user, assert on what's on screen.
Avoid: asserting on internal state, hook calls or component structure.
Boundaries: mock the network, not your own components.
"I use React Testing Library with Jest or Vitest. The idea is to test the component the way a user uses it. I render it, find elements the way a person would, by role, label or visible text, then interact with user-event, like typing into a field or clicking a button, and assert on what's on screen afterwards. For a login form I'd check that submitting with an empty email shows an error message, not that some state variable is set. I avoid testing internals like state values, which hooks were called, or the exact component tree, because those break on every refactor even when nothing is wrong for the user. Querying by role has a side benefit: if I can't find a button by its role and name, a screen reader user probably can't either. For network calls I mock at the fetch or HTTP level."
Testing that setState was called or reading component internals, instead of checking what the user sees.
Mock the network: stub fetch to resolve with known data, fresh for each test.
Check loading: assert the loading text right after render.
Wait properly: use an async findBy query, which retries until the element appears or times out.
Also test: the error path, by making the mock reject.
"First I control the network. I replace fetch with a fresh mock before each test, so tests don't leak into each other, and make it resolve to a response whose json returns a known user. Then I render the component and check the loading text is there straight away, with a getBy query, since it should appear synchronously. For the name, I don't add a sleep. I await a findBy query, which keeps retrying until the element shows up or a timeout passes. Here I look for a heading with the user's name. That's the whole happy path. I'd write a second test where the mock rejects and check the error message shows. If I see act warnings, it usually means something updated after the test ended, which tells me I forgot to wait for something."
import { render, screen } from '@testing-library/react';
import UserProfile from './UserProfile';
beforeEach(() => {
global.fetch = jest.fn();
});
test('shows the user after loading', async () => {
fetch.mockResolvedValue({
ok: true,
json: async () => ({ name: 'Asha Rao' }),
});
render(<UserProfile id="42" />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
expect(
await screen.findByRole('heading', { name: 'Asha Rao' })
).toBeInTheDocument();
});
Using a fixed setTimeout in the test to wait for data, or asserting on the component's state instead of the screen.
Symptom: what the user felt and on which screen.
Measure: the React DevTools Profiler, and highlighting renders, to find what rendered and why.
Fix: the specific cause, such as unstable context values, state kept too high, or a huge list.
Proof: profile again and confirm the change with the people who reported it.
"At my last company we had an analytics dashboard where typing in the filter box lagged badly. Instead of guessing, I recorded a session in the React DevTools Profiler while typing. It showed the whole dashboard, every chart and table, re-rendering on each keystroke. The cause was a single context provider at the top holding the filter text together with the loaded data, and its value was a new object on every render, so every consumer updated. I moved the filter input's state down into the filter bar, so typing only rendered that bar, and applied the filter when the user paused. I also split the context into data and settings and memoised the value. When I profiled again, a keystroke only rendered the input, and typing felt instant. The team that reported it confirmed it the same week."
A story with no measurement, where the fix was adding useMemo everywhere and hoping.
Signal: how the bug showed up, whether from users, logs or monitoring.
Trace: how you narrowed it down to one effect and one dependency.
Root cause: why the effect ran when it shouldn't have, or read an old value.
Prevention: the fix, plus a test or a lint rule so it stays fixed.
"In a project I worked on, the backend team noticed one endpoint getting far more calls than we had users. In the browser's network tab I saw the orders page firing the same request over and over while it sat open. I found the effect: it depended on a filters object that came from a custom hook, and the hook built a new object on every render. When the response arrived, we set state, the page re-rendered, the hook returned a fresh object, and the effect ran again. The screen looked fine because the data was the same each time, but it kept hitting the server. I fixed the hook to memoise its return value and changed the effect to depend on the actual filter fields. Then I added a test that counts fetch calls after render, and we turned the exhaustive-deps lint rule into an error."
Blaming React or the backend, or describing a fix that only hides the symptom, like a guard flag.
Why: what the old component made hard, such as bugs, slow changes or no tests.
Safety net: behaviour tests written before touching the code.
Steps: small pull requests, pulling logic into custom hooks and child components.
Result: what got easier, and anything you'd do differently.
"At my last company we had a checkout component, a class component of well over a thousand lines, with pricing, validation and API calls all mixed together. Every change broke something. Before touching it, I wrote tests with Testing Library that covered what a user does: adding a coupon, changing the address, seeing errors. Then I went in small pull requests. First I pulled the pricing logic out into plain functions with their own unit tests. Then I converted the class to a function component and moved the data loading into a custom hook, and split the form sections into child components. Each step shipped separately, so if something broke we knew exactly which change did it. One bug did slip through, a double submit, so I added a test for it before fixing it. The component ended up small enough that new people could change it confidently."
A big-bang rewrite with no tests, or a story where the only reason was that hooks are newer.
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 resume and notes are never stored on our servers. It stays out of screen share on every plan; only you can see it.