Edge Cases, Debugging & Trick Questions
Every React developer eventually hits something that makes them stare at their screen and say "…why?" This guide covers the gotchas, edge cases, and trick questions that trip up even experienced developers. Understanding why these things happen turns confusion into confidence.
Why useEffect Runs Twice
This is probably the #1 React confusion. You write a simple effect, and it fires twice on mount. You add console.log and see it printed twice. You think your code is broken. It's not.
Think of it like this: React is a fire drill instructor. In development mode with Strict Mode enabled, React deliberately mounts your component, unmounts it, and mounts it again — to check that your cleanup logic works properly. In production, it only runs once.
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState(null);
useEffect(() => {
console.log("Effect running!"); // Prints TWICE in development
const controller = new AbortController();
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then((res) => res.json())
.then(setUser)
.catch((err) => {
if (err.name !== "AbortError") console.error(err);
});
return () => {
console.log("Cleanup running!"); // This runs between the two mounts
controller.abort();
};
}, [userId]);
return <div>{user ? user.name : "Loading..."}</div>;
}Here's the timeline in development:
1. Component mounts → Effect runs → "Effect running!"
2. React unmounts it → Cleanup runs → "Cleanup running!"
3. React re-mounts it → Effect runs again → "Effect running!"Why does React do this? To catch bugs like forgotten cleanup. If your effect subscribes to something but doesn't unsubscribe, Strict Mode reveals that immediately because the first subscription is left dangling.
Common Mistake: Trying to "fix" the double-run by removing
<StrictMode>from your app. Don't! The double-run only happens in development. If your code breaks because of it, that means you have a real bug that would cause problems in production (like memory leaks or stale subscriptions).
How to Handle It
The fix is always the same: write proper cleanup.
// ❌ No cleanup — subscribes twice in Strict Mode, leaks in production
useEffect(() => {
const ws = new WebSocket("wss://api.example.com");
ws.onmessage = (event) => setMessages((prev) => [...prev, event.data]);
}, []);
// ✅ With cleanup — Strict Mode double-run is harmless
useEffect(() => {
const ws = new WebSocket("wss://api.example.com");
ws.onmessage = (event) => setMessages((prev) => [...prev, event.data]);
return () => ws.close();
}, []);Is React Synchronous or Asynchronous?
The trick answer: it's both. This is a popular interview question designed to see if you understand React's rendering model.
Think of it like this: Imagine a restaurant kitchen. Orders (state updates) come in, and the kitchen (React) batches them together for efficiency. It doesn't cook each item the moment it's ordered — it groups things smartly. But the cooking itself (rendering) happens synchronously.
State Updates Are Batched
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
// count is STILL 0 here — state updates are batched
// Result: count becomes 1, not 3
console.log(count); // 0
};
return <button onClick={handleClick}>Count: {count}</button>;
}Each setCount(count + 1) uses the same stale count value (0). All three calls say "set count to 0 + 1". React batches them and only re-renders once.
// ✅ Fix: use the updater function to read the latest state
const handleClick = () => {
setCount((prev) => prev + 1); // 0 → 1
setCount((prev) => prev + 1); // 1 → 2
setCount((prev) => prev + 1); // 2 → 3
// Result: count becomes 3
};React 18+ Automatic Batching
Before React 18, batching only happened inside event handlers. Now it happens everywhere:
// React 18+ — ALL of these are batched into a single re-render
function SearchResults() {
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const search = async (query: string) => {
setLoading(true);
setError(null);
try {
const data = await fetch(`/api/search?q=${query}`);
const json = await data.json();
setResults(json); // These three updates
setLoading(false); // are batched into
setError(null); // ONE re-render
} catch (err) {
setError(err);
setLoading(false);
}
};
return <div>{loading ? "Loading..." : `${results.length} results`}</div>;
}Concurrent Features (React 18+)
React can also interrupt rendering to stay responsive. This is the truly "asynchronous" part:
import { useTransition } from "react";
function FilterableList({ items }: { items: string[] }) {
const [filter, setFilter] = useState("");
const [isPending, startTransition] = useTransition();
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setFilter(value); // Urgent: update input immediately
startTransition(() => {
// Non-urgent: React can interrupt this if the user keeps typing
setFilteredItems(items.filter((item) => item.includes(value)));
});
};
return (
<div>
<input value={filter} onChange={handleChange} />
{isPending ? <p>Updating...</p> : <ItemList items={filteredItems} />}
</div>
);
}The complete answer: Rendering is synchronous, state updates are batched (asynchronous-like), and concurrent features allow React to interrupt and prioritize work.
Can Refs Cause Memory Leaks?
Yes, they absolutely can. This is a trick question that catches people who think refs are "just DOM pointers."
Think of it like this: A ref is like a sticky note with an address on it. If the building at that address gets demolished (component unmounts) but you still have the sticky note and keep trying to visit, you're holding onto something that should be gone — that's a memory leak.
// ❌ Memory leak — ref holds onto DOM node after unmount
function Problematic() {
const chartRef = useRef<HTMLCanvasElement>(null);
const chartInstance = useRef<ChartLibrary | null>(null);
useEffect(() => {
if (chartRef.current) {
chartInstance.current = new ChartLibrary(chartRef.current, {
data: bigDataset,
});
}
// No cleanup! chartInstance holds reference to DOM + data
}, []);
return <canvas ref={chartRef} />;
}When this component unmounts, the ChartLibrary instance still exists in memory, holding onto the canvas DOM node and the big dataset. The garbage collector can't clean it up because chartInstance.current still references it.
// ✅ Fixed — clean up the reference
function Fixed() {
const chartRef = useRef<HTMLCanvasElement>(null);
const chartInstance = useRef<ChartLibrary | null>(null);
useEffect(() => {
if (chartRef.current) {
chartInstance.current = new ChartLibrary(chartRef.current, {
data: bigDataset,
});
}
return () => {
chartInstance.current?.destroy();
chartInstance.current = null;
};
}, []);
return <canvas ref={chartRef} />;
}Common Ref Leak Patterns
// ❌ Leak: storing event listeners in refs without cleanup
function Leaky() {
const handlerRef = useRef<((e: MouseEvent) => void) | null>(null);
useEffect(() => {
handlerRef.current = (e: MouseEvent) => {
console.log(e.clientX, e.clientY);
};
window.addEventListener("mousemove", handlerRef.current);
// Forgot to remove the listener!
}, []);
return <div>Move your mouse</div>;
}
// ✅ Fixed
function NotLeaky() {
const handlerRef = useRef<((e: MouseEvent) => void) | null>(null);
useEffect(() => {
handlerRef.current = (e: MouseEvent) => {
console.log(e.clientX, e.clientY);
};
window.addEventListener("mousemove", handlerRef.current);
return () => {
if (handlerRef.current) {
window.removeEventListener("mousemove", handlerRef.current);
handlerRef.current = null;
}
};
}, []);
return <div>Move your mouse</div>;
}Common Mistake: Thinking that refs are automatically cleaned up when a component unmounts. They're not. React clears
ref.currentfor DOM refs, but any library instances, event listeners, or subscriptions stored in refs must be manually cleaned up.
Why Duplicate Keys Warning Happens
You've seen this warning: Warning: Each child in a list should have a unique "key" prop. Or worse: Warning: Encountered two children with the same key.
Think of it like this: Keys are name badges at a conference. If two people have the same badge, the check-in system gets confused — it can't tell them apart. React uses keys to track which items changed, were added, or were removed. Duplicate keys = confusion.
The Race Condition Scenario
The trickiest case is when duplicate keys come from race conditions in data fetching:
// ❌ Race condition creating duplicate keys
function SearchResults() {
const [results, setResults] = useState([]);
const search = async (query: string) => {
const response = await fetch(`/api/search?q=${query}`);
const data = await response.json();
setResults(data); // If user types fast, two responses may arrive
};
return (
<ul>
{results.map((item) => (
<li key={item.id}>{item.name}</li>
// If two API responses merge, same IDs appear twice
))}
</ul>
);
}When a user types "re" then "rea" quickly, both API calls fire. If the "rea" response arrives first and "re" arrives second, you overwrite newer results with older ones. If you append instead, you get duplicates.
// ✅ Fix: cancel previous requests
function SearchResults() {
const [results, setResults] = useState([]);
const controllerRef = useRef<AbortController | null>(null);
const search = async (query: string) => {
controllerRef.current?.abort();
controllerRef.current = new AbortController();
try {
const response = await fetch(`/api/search?q=${query}`, {
signal: controllerRef.current.signal,
});
const data = await response.json();
setResults(data);
} catch (err) {
if (err.name !== "AbortError") console.error(err);
}
};
return (
<ul>
{results.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}Other Causes of Duplicate Keys
// ❌ Using array index as key — duplicates when list changes
{items.map((item, index) => (
<li key={index}>{item.name}</li>
))}
// ❌ Using non-unique field as key
{users.map((user) => (
<li key={user.department}>{user.name}</li>
// Multiple users in same department = duplicate keys
))}
// ✅ Use a truly unique identifier
{items.map((item) => (
<li key={item.id}>{item.name}</li>
))}Common Mistake: Using array index as a key. It "works" and silences the warning, but it causes bugs when items are reordered, inserted, or deleted. React will reuse the wrong DOM nodes and you'll see stale data, broken animations, or lost input state.
Why Components Re-render Multiple Times
"My component renders 5 times just from clicking one button!" This is the complaint that sends developers down rabbit holes. Let's understand why.
Think of it like this: React re-renders are like refreshing a spreadsheet. When one cell changes, every cell that depends on it recalculates. Re-rendering is React's recalculation — it's usually fast and intentional.
The Three Causes of Re-renders
function Parent() {
const [count, setCount] = useState(0);
console.log("Parent renders"); // Cause 1: own state changed
return (
<div>
<button onClick={() => setCount((c) => c + 1)}>
Count: {count}
</button>
<Child name="Alice" /> {/* Cause 2: parent re-rendered */}
</div>
);
}
function Child({ name }: { name: string }) {
console.log("Child renders"); // Re-renders every time Parent does!
return <p>Hello {name}</p>;
}Cause 1: State changes. When count changes, Parent re-renders.
Cause 2: Parent re-renders. When Parent re-renders, Child re-renders too — even though name didn't change. This is React's default behavior.
Cause 3: Strict Mode. In development, React renders twice to detect side effects (see the useEffect section above).
Diagnosing Unnecessary Re-renders
import { memo, useMemo, useCallback } from "react";
// Step 1: Wrap child in React.memo — only re-renders if props change
const Child = memo(function Child({ name }: { name: string }) {
console.log("Child renders");
return <p>Hello {name}</p>;
});
function Parent() {
const [count, setCount] = useState(0);
// Step 2: Memoize objects/arrays passed as props
const config = useMemo(() => ({ theme: "dark" }), []);
// Step 3: Memoize callbacks passed as props
const handleAction = useCallback(() => {
console.log("action");
}, []);
return (
<div>
<button onClick={() => setCount((c) => c + 1)}>
Count: {count}
</button>
<Child name="Alice" /> {/* Now only re-renders if "Alice" changes */}
</div>
);
}Common Mistake: Wrapping everything in
React.memoanduseMemo"just in case." Memoization has a cost — it uses memory and adds comparison logic. Only optimize when you've measured a real performance problem. The React profiler is your friend.
The Context Re-render Trap
// ❌ Every component using this context re-renders when ANY value changes
const AppContext = createContext({
user: null,
theme: "light",
notifications: [],
});
function Header() {
const { user } = useContext(AppContext);
// Re-renders when theme or notifications change too!
return <h1>Welcome {user?.name}</h1>;
}// ✅ Split contexts so consumers only re-render for relevant changes
const UserContext = createContext<User | null>(null);
const ThemeContext = createContext("light");
const NotificationContext = createContext<Notification[]>([]);
function Header() {
const user = useContext(UserContext);
// Only re-renders when user changes
return <h1>Welcome {user?.name}</h1>;
}Common React Debugging Strategies
When something isn't working right, here's a systematic approach:
1. Console.log the Right Things
function BuggyComponent({ data }: { data: Item[] }) {
console.log("Render:", { dataLength: data.length }); // Track renders
useEffect(() => {
console.log("Effect fired:", { data }); // Track effect triggers
return () => console.log("Cleanup ran"); // Track cleanups
}, [data]);
const filtered = data.filter((item) => item.active);
console.log("Filtered:", { filtered }); // Track derived state
return <List items={filtered} />;
}2. Use the "Why Did This Render?" Technique
function useWhyDidYouRender(name: string, props: Record<string, any>) {
const previousProps = useRef(props);
useEffect(() => {
const changes: Record<string, { from: any; to: any }> = {};
Object.entries(props).forEach(([key, value]) => {
if (previousProps.current[key] !== value) {
changes[key] = { from: previousProps.current[key], to: value };
}
});
if (Object.keys(changes).length > 0) {
console.log(`[${name}] re-rendered because:`, changes);
}
previousProps.current = props;
});
}
function MyComponent(props: MyProps) {
useWhyDidYouRender("MyComponent", props);
return <div>...</div>;
}3. Check for Infinite Loops
// ❌ Infinite loop — object created every render triggers effect,
// effect updates state, which triggers re-render
function Infinite() {
const [data, setData] = useState([]);
const options = { page: 1 }; // New object every render!
useEffect(() => {
fetch("/api/data", options).then((res) => res.json()).then(setData);
}, [options]); // options is different every render → infinite loop
}
// ✅ Fixed — memoize the dependency
function Fixed() {
const [data, setData] = useState([]);
const options = useMemo(() => ({ page: 1 }), []);
useEffect(() => {
fetch("/api/data", options).then((res) => res.json()).then(setData);
}, [options]);
}React DevTools Tips
The React DevTools browser extension is your most powerful debugging weapon. Here's how to use it effectively:
The Profiler
The Profiler tab records renders and shows you exactly what re-rendered and why.
1. Open React DevTools → Profiler tab
2. Click the record button (blue circle)
3. Interact with your app
4. Stop recording
5. Click on any component to see:
- Why it rendered (props changed, state changed, parent re-rendered)
- How long it took
- How many times it renderedThe Components Tab
Useful features:
- Click any component → see its current props, state, and hooks
- Search for components by name
- Click the "eye" icon → highlights the component in the page
- Right-click → "Log this component" → dumps it to console
- Edit state/props live to test different scenariosHighlight Updates
Settings (gear icon) → General → "Highlight updates when components render"
This adds a colored flash around components when they re-render:
- Blue = infrequent re-renders (good)
- Green = moderate
- Yellow = frequent
- Red = very frequent (investigate!)This is incredibly useful for spotting unnecessary re-renders. Type in a search box and watch — if half the page lights up red, you have an optimization opportunity.
Debugging Quick Reference
| Problem | Tool/Technique |
|---|---|
| Why did this re-render? | React DevTools Profiler |
| What's the current state? | Components tab → inspect hooks |
| Is my effect running too often? | console.log in effect + dependency check |
| Infinite render loop? | Check effect dependencies for objects/arrays created during render |
| State not updating? | Check if you're mutating state instead of creating new references |
| Component not re-rendering? | Check if React.memo is blocking updates or context is stale |
| Memory leak? | Chrome DevTools → Memory tab → take heap snapshots before/after |
| Slow renders? | React DevTools Profiler → look for long render times |