React Debug and Code Review: 6 Common Hook Bugs and How to Fix Them
Code review and live debugging rounds are among the most revealing stages of a front-end engineering interview. Rather than asking you to recite API definitions, interviewers present short, realistic component snippets containing subtle defects and observe how you inspect, diagnose, and refactor the code.
In these rounds, interviewers evaluate your understanding of React's render lifecycle, hook dependencies, reference stability, and concurrency invariants. A strong candidate identifies the root cause immediately, explains the user-facing symptoms, refactors the code cleanly, and articulates the trade-offs out loud.
This article reviews 6 classic React hook bugs, targeting developers with 0 to 5 years of experience preparing for junior to mid-level engineering interviews.
| # | Question | Level |
|---|---|---|
| 1 | How do you fix an infinite re-render loop triggered by object dependencies in useEffect? | Junior |
| 2 | Why does direct array mutation fail to trigger a re-render, and how do you resolve it? | Junior |
| 3 | How do you fix a stale closure inside a timer callback in useEffect? | Mid |
| 4 | Why should derived state not be synced inside useEffect, and how do you refactor it? | Mid |
| 5 | How do you eliminate asynchronous race conditions in data-fetching hooks? | Mid |
| 6 | Why is reading and writing useRef during the render phase dangerous, and how do you fix it? | Senior |
Q1. How do you fix an infinite re-render loop triggered by object dependencies in useEffect?
Level: Junior
What the interviewer is checking: The interviewer is testing your understanding of referential equality in JavaScript, React's dependency comparison mechanics (Object.is), and how re-renders trigger effect execution.
Buggy Code:
import React, { useState, useEffect } from 'react';
export function UserProfile({ userId }: { userId: string }) {
const [userData, setUserData] = useState<any>(null);
// Bug: options object is re-created with a new reference on every render
const options = { timeout: 5000 };
useEffect(() => {
fetch(`/api/users/${userId}`, options)
.then((res) => res.json())
.then((data) => setUserData(data));
}, [userId, options]); // options changes every render, triggering infinite loop
return <div>{userData ? userData.name : 'Loading...'}</div>;
}
Symptom:
The component triggers non-stop network requests, freezing the browser tab or throwing React's runtime error: "Maximum update depth exceeded".
Root Cause:
Non-primitive values (objects, arrays, functions) declared inside a component body receive a brand-new memory reference on every render. Because useEffect compares dependencies using Object.is, options !== previousOptions is always true, causing the effect to run on every commit. The effect then calls setUserData, which triggers another render, creating an infinite loop.
Fixed Code:
import React, { useState, useEffect } from 'react';
// Solution: Move static configuration outside the component,
// or extract primitive values inside the effect
const DEFAULT_OPTIONS = { timeout: 5000 };
export function UserProfile({ userId }: { userId: string }) {
const [userData, setUserData] = useState<any>(null);
useEffect(() => {
fetch(`/api/users/${userId}`, DEFAULT_OPTIONS)
.then((res) => res.json())
.then((data) => setUserData(data));
}, [userId]); // Only re-runs when userId changes
return <div>{userData ? userData.name : 'Loading...'}</div>;
}
Short answer (say this first):
The infinite loop happens because the options object is declared inside the component body, creating a new reference on every render pass. Because useEffect uses Object.is for dependency checks, it sees options as a new value every time, re-runs the effect, updates state, and loops infinitely. We fix it by moving the static object outside the component or memoizing it with useMemo.
Detailed explanation:
- Referential Equality: In JavaScript,
{ timeout: 5000 } !== { timeout: 5000 }because they occupy distinct heap addresses. - Dependency Invariant: React effects should depend on primitive values whenever possible. If an object does not depend on component state or props, hoist it outside the component entirely.
- Dynamic Objects: If the object must depend on local variables, wrap its definition in
useMemoor pass its primitive properties directly into the effect.
Common mistakes:
- Suppressing the ESLint
react-hooks/exhaustive-depswarning by removingoptionsfrom the dependency array without addressing the unstable reference.
Likely follow-up questions:
- When would you use
useMemoinstead of moving the object outside? — UseuseMemowhen the object depends on internal props or state that change over time and cannot be statically declared outside.
Q2. Why does direct array mutation fail to trigger a re-render, and how do you resolve it?
Level: Junior
What the interviewer is checking: The interviewer wants to verify that you understand state immutability in React and how React's reconciler detects state changes.
Buggy Code:
import React, { useState } from 'react';
export function TodoList() {
const [todos, setTodos] = useState<string[]>(['Buy groceries', 'Walk dog']);
const [input, setInput] = useState('');
const handleAdd = () => {
if (!input.trim()) return;
// Bug: mutating existing array in-place
todos.push(input);
setTodos(todos);
setInput('');
};
return (
<div>
<input value={input} onChange={(e) => setInput(e.target.value)} />
<button onClick={handleAdd}>Add Todo</button>
<ul>{todos.map((todo, idx) => <li key={idx}>{todo}</li>)}</ul>
</div>
);
}
Symptom: Clicking "Add Todo" clears the input field, but the new todo item does not appear on the screen until some other unrelated state change forces a re-render.
Root Cause:
todos.push(input) mutates the existing array in memory. When setTodos(todos) is called, React compares the previous state with the new state using Object.is(previousState, nextState). Because both variables point to the exact same array reference in memory, React concludes that state has not changed and bails out of re-rendering.
Fixed Code:
import React, { useState } from 'react';
export function TodoList() {
const [todos, setTodos] = useState<string[]>(['Buy groceries', 'Walk dog']);
const [input, setInput] = useState('');
const handleAdd = () => {
if (!input.trim()) return;
// Solution: Create a fresh array copy using the spread operator
setTodos((prevTodos) => [...prevTodos, input.trim()]);
setInput('');
};
return (
<div>
<input value={input} onChange={(e) => setInput(e.target.value)} />
<button onClick={handleAdd}>Add Todo</button>
<ul>{todos.map((todo) => <li key={todo}>{todo}</li>)}</ul>
</div>
);
}
Short answer (say this first):
React uses shallow reference equality via Object.is to determine whether state has changed. When you mutate an array using todos.push(), the array's reference remains identical, so React skips re-rendering. You fix this by treating state as immutable and passing a new array copy using the spread operator [...prevTodos, newItem].
Detailed explanation:
- Bailout Optimization: React optimizes performance by skipping renders if
Object.is(newState, oldState)evaluates to true. - Immutable Updates: Creating a new reference informs the reconciliation engine that data has changed, scheduling a virtual DOM diff and DOM commit.
- Functional Updater: Using
setTodos(prev => [...prev, newItem])ensures that state updates correctly even when multiple mutations are queued in the same tick.
Common mistakes:
- Using
todos.slice()or[...todos]before pushing, but accidentally mutating nested objects within the array.
Likely follow-up questions:
- How do you handle deeply nested state updates immutably? — Use structured spreading, libraries like Immer (
produce), or redesign component state to be flat and normalized.
Q3. How do you fix a stale closure inside a timer callback in useEffect?
Level: Mid
What the interviewer is checking: The interviewer wants to evaluate your understanding of how JavaScript closures interact with React's functional render passes across asynchronous intervals.
Buggy Code:
import React, { useState, useEffect } from 'react';
export function AutoCounter() {
const [count, setCount] = useState(0);
useEffect(() => {
// Bug: captures `count = 0` from initial render
const intervalId = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(intervalId);
}, []); // Missing count dependency
return <div>Count: {count}</div>;
}
Symptom:
The counter increments from 0 to 1 after one second, but then remains permanently stuck at 1.
Root Cause:
Because the dependency array is empty ([]), the effect runs only once on mount. The setInterval callback closes over the lexical environment of the initial render, where count is 0. Every second, the timer evaluates setCount(0 + 1), repeatedly setting the state to 1.
Fixed Code:
import React, { useState, useEffect } from 'react';
export function AutoCounter() {
const [count, setCount] = useState(0);
useEffect(() => {
// Solution: Functional updater accesses fresh queue value
const intervalId = setInterval(() => {
setCount((prevCount) => prevCount + 1);
}, 1000);
return () => clearInterval(intervalId);
}, []); // Clean and stable dependency array
return <div>Count: {count}</div>;
}
Short answer (say this first):
The timer callback suffers from a stale closure because it closed over count during the initial render pass when count was 0. Every second, it executes setCount(0 + 1), constantly setting the value to 1. We fix this by passing a functional state updater setCount(prev => prev + 1), which reads the latest state directly from React's internal queue without needing count in the closure.
Detailed explanation:
- Closure Scope Lifetime: A function defined in render N permanently holds references to variables in render N.
- Why adding
[count]is sub-optimal: Addingcountto the dependency array fixes the staleness, but tears down and re-creates the interval on every single tick (clearIntervalandsetInterval), causing timer jitter. - The Functional Updater Pattern:
setState(prev => ...)decouples the state update action from the current render's state variable, allowing the interval to persist seamlessly across the component's lifecycle.
Common mistakes:
- Adding
countto the dependency array without realizing that clearing and re-setting the interval every second degrades timing accuracy.
Likely follow-up questions:
- How would you access an external callback that changes frequently without restarting the timer? — Store the callback in a
useRefand updateref.current = callbackon each render, or in React 19 useuseEffectEvent.
Q4. Why should derived state not be synced inside useEffect, and how do you refactor it?
Level: Mid
What the interviewer is checking: The interviewer is verifying whether you know when not to use useEffect, preventing anti-patterns that degrade rendering performance and cause visual flashes.
Buggy Code:
import React, { useState, useEffect } from 'react';
export function ProductCatalog({ products, filterText }: { products: any[]; filterText: string }) {
// Bug: storing derived state and synchronizing via effect
const [filteredProducts, setFilteredProducts] = useState<any[]>([]);
useEffect(() => {
const matching = products.filter((p) =>
p.name.toLowerCase().includes(filterText.toLowerCase())
);
setFilteredProducts(matching);
}, [products, filterText]);
return (
<ul>{filteredProducts.map((p) => <li key={p.id}>{p.name}</li>)}</ul>
);
}
Symptom: When the user types into the filter input, the list exhibits a subtle visual flash or lag. The component renders once with stale products, commits to the screen, and then immediately re-renders with the filtered list.
Root Cause:
filteredProducts is derived purely from existing props (products and filterText). By storing it in state and updating it inside useEffect, you force React to execute two complete render cycles for every input change: first to render the component with props, and second when the effect fires setFilteredProducts.
Fixed Code:
import React, { useMemo } from 'react';
export function ProductCatalog({ products, filterText }: { products: any[]; filterText: string }) {
// Solution: Calculate derived state inline during render, optionally using useMemo
const filteredProducts = useMemo(() => {
return products.filter((p) =>
p.name.toLowerCase().includes(filterText.toLowerCase())
);
}, [products, filterText]);
return (
<ul>{filteredProducts.map((p) => <li key={p.id}>{p.name}</li>)}</ul>
);
}
Short answer (say this first):
Synchronizing derived state in useEffect is an anti-pattern because it causes redundant render cascades: React paints the screen once with outdated data, then immediately runs the effect and re-renders with the new state. If a value can be computed entirely from existing props or state, compute it directly during rendering or wrap it in useMemo if the calculation is expensive.
Detailed explanation:
- Single Render Pass: Calculating values during rendering keeps state single-source-of-truth and ensures the UI updates in a single synchronous commit.
- Avoiding Cascading Renders: State updates inside
useEffectalways schedule an additional re-render after the DOM has been updated. - When to Use
useMemo: For lightweight filtering (e.g. fewer than 1,000 items), compute directly withoutuseMemo. If filtering involves thousands of items or complex transformations, memoize withuseMemo.
Common mistakes:
- Mirroring props into state in a
useState(prop)call and expecting changes to the prop to automatically update state.
Likely follow-up questions:
- According to official React documentation, what are valid use cases for
useEffect? — Synchronizing with external systems outside React, such as setting up WebSockets, manipulating non-React DOM widgets, or handling browser APIs.
Q5. How do you eliminate asynchronous race conditions in data-fetching hooks?
Level: Mid
What the interviewer is checking: The interviewer wants to assess whether you understand asynchronous lifecycles in single-page apps and can prevent race conditions when query parameters change rapidly.
Buggy Code:
import React, { useState, useEffect } from 'react';
export function SearchResults({ query }: { query: string }) {
const [results, setResults] = useState<string[]>([]);
useEffect(() => {
// Bug: No cancellation or cleanup; race condition if query changes quickly
fetch(`/api/search?q=${query}`)
.then((res) => res.json())
.then((data) => setResults(data.items));
}, [query]);
return <div>{results.join(', ')}</div>;
}
Symptom: If a user rapidly types "cat" then "dog", the network request for "cat" may finish after the request for "dog", causing the screen to display search results for "cat" even though "dog" is selected.
Root Cause: Network requests resolve non-deterministically. If request A takes 300ms and subsequent request B takes 100ms, request B resolves first, followed by request A. Because the effect callback has no cleanup or active check, request A's response overwrites request B's response, leaving the UI in an inconsistent state.
Fixed Code:
import React, { useState, useEffect } from 'react';
export function SearchResults({ query }: { query: string }) {
const [results, setResults] = useState<string[]>([]);
useEffect(() => {
const controller = new AbortController();
fetch(`/api/search?q=${query}`, { signal: controller.signal })
.then((res) => res.json())
.then((data) => setResults(data.items))
.catch((err) => {
if (err.name !== 'AbortError') {
console.error('Fetch failed:', err);
}
});
// Solution: Abort pending request when query changes or component unmounts
return () => controller.abort();
}, [query]);
return <div>{results.join(', ')}</div>;
}
Short answer (say this first):
This is a classic asynchronous race condition where an older, slower network request resolves after a newer request and overwrites the state. We fix it by creating an AbortController inside the effect, passing its signal to fetch, and calling controller.abort() in the cleanup function so stale requests are canceled whenever dependencies change or the component unmounts.
Detailed explanation:
- Non-deterministic Latency: Server response times vary; request ordering cannot guarantee response ordering.
- Effect Cleanup Lifecycle: React invokes an effect's cleanup function immediately before re-running the effect with new dependencies, as well as on unmount.
- Alternative (Boolean Flag): In environments where requests cannot be aborted, maintain an
let isCurrent = true;flag in the effect and setisCurrent = false;in the cleanup function, verifyingif (isCurrent) setResults(...).
Common mistakes:
- Forgetting to handle
AbortErrorin the.catchblock, causing benign request cancellations to be logged as network failures.
Likely follow-up questions:
- Why do modern React applications prefer TanStack Query or SWR for data fetching? — They provide out-of-the-box request deduplication, caching, automatic cancellation, and optimistic mutations without manual
useEffectboilerplates.
Q6. Why is reading and writing useRef during the render phase dangerous, and how do you fix it?
Level: Senior
What the interviewer is checking: The interviewer is probing your mastery of React's Concurrent Mode architecture, component purity invariants, and the strict rules governing ref mutation.
Buggy Code:
import React, { useRef } from 'react';
export function RenderTracker() {
const renderCount = useRef(0);
// Bug: mutating ref directly during rendering
renderCount.current += 1;
// Bug: reading ref during render to produce JSX
return <div>Component rendered: {renderCount.current} times</div>;
}
Symptom: In React 18 and 19 Concurrent Mode or React Strict Mode, the reported render count is unpredictable, frequently doubling or displaying inconsistent numbers across concurrent rendering trees.
Root Cause:
React components must be pure functions of props and state during the render phase. React may render a component multiple times without committing to the DOM (for example, when an update is interrupted by a higher-priority task or during Strict Mode double-invocations). Modifying ref.current during render introduces a side effect that corrupts state when renders are discarded, and reading it makes the render output non-deterministic.
Fixed Code:
import React, { useRef, useEffect } from 'react';
export function RenderTracker() {
const renderCount = useRef(0);
// Solution: Mutate refs inside useEffect, which only executes upon committed renders
useEffect(() => {
renderCount.current += 1;
console.log(`Committed renders: ${renderCount.current}`);
});
return <div>Inspect console for committed render telemetry</div>;
}
Short answer (say this first):
React components must remain pure with zero side effects during the render phase. In Concurrent React, the engine can render a component, pause, abandon the work, or re-render it before committing to the screen. Mutating or reading a ref during render leads to side effects and non-deterministic UI. Ref mutations must be restricted to useEffect or user event handlers.
Detailed explanation:
- Render Phase vs Commit Phase: The render phase computes the virtual DOM tree and must have no side effects. The commit phase applies DOM mutations and executes effects.
- Strict Mode Invariant: In development, React Strict Mode renders components twice to detect side effects. Code that mutates refs during render fails this check immediately.
- The Ref Contract: According to the official React documentation (react.dev), you should never write or read
ref.currentduring the render phase except for lazy initialization (if (ref.current === null) ref.current = new Instance()).
Common mistakes:
- Using
useRefto store values needed to render the UI instead of usinguseState, expecting ref mutations to trigger visual updates.
Likely follow-up questions:
- What is the only valid exception to reading or writing
ref.currentduring rendering? — Lazy initialization of the ref value on the very first render, provided it only initializes whenref.current === nulland does not mutate external state.
Preparation Checklist
- Practise identifying unstable object and function references that trigger infinite loops in
useEffect. - Review your code for direct state mutations and replace them with immutable spread operations.
- Explain why functional state updaters (
setState(prev => ...)) prevent stale closures in timers. - Refactor redundant
useEffecthooks that calculate derived state into direct inline computations. - Implement an
AbortControllercleanup pattern to eliminate race conditions in asynchronous fetches. - Memorize the distinction between the render phase and commit phase regarding
useRefmutations.
Conclusion
Technical code review questions test whether you can identify runtime issues and understand the mechanics that cause them. By internalizing React's shallow comparison model, immutability requirements, closure lifecycles, and concurrent rendering invariants, you will demonstrate the debugging precision and architectural judgment that top front-end interviewers look for.