Edge Cases & Trick Questions
These are the questions interviewers pull out to see if you really understand React â or if you've just been writing it on autopilot. They target the gaps between "I use React" and "I understand React." Get these right and the interviewer knows you've debugged real production code.
Q24: Why Am I Getting a "Duplicate Keys" Warning?
Interview Question: "You're building a feed that fetches data on scroll. Users report seeing duplicate items and you see a 'duplicate key' warning in the console. What's happening?"
Think of it like a librarian shelving books. Each book has a unique catalog number (the key). If two books have the same catalog number, the librarian gets confused â they might shelve a new book over an old one, or lose track of which is which. React's key prop is that catalog number, and duplicates cause real bugs.
The Root Cause: Race Conditions in Pagination
The most common scenario: your infinite scroll fires two fetch requests before the first one completes. Both requests return overlapping data, and you concatenate both into state:
// The bug
function useFeed() {
const [items, setItems] = useState<Post[]>([]);
async function loadMore() {
const newItems = await fetchPage(cursor);
setItems(prev => [...prev, ...newItems]);
}
return { items, loadMore };
}If loadMore is called twice quickly (user scrolls fast, IntersectionObserver fires twice), both calls use the same cursor. Both return the same page. You end up with duplicate items in state â and duplicate keys in the list.
The Fix: Prevent Duplicate Fetches
function useFeed() {
const [items, setItems] = useState<Post[]>([]);
const isFetching = useRef(false);
const cursorRef = useRef<string | null>(null);
async function loadMore() {
if (isFetching.current) return;
isFetching.current = true;
try {
const response = await fetchPage(cursorRef.current);
cursorRef.current = response.nextCursor;
setItems(prev => {
const existingIds = new Set(prev.map(item => item.id));
const unique = response.items.filter(item => !existingIds.has(item.id));
return [...prev, ...unique];
});
} finally {
isFetching.current = false;
}
}
return { items, loadMore };
}Three defenses:
- Guard with a ref â
isFetching.currentprevents concurrent calls - Deduplicate on merge â Filter out items whose IDs already exist in state
- Cursor in a ref â Update the cursor immediately so the next call gets the right page
Why This Matters Beyond Warnings
Duplicate keys don't just cause console warnings. React uses keys to match elements between renders. With duplicates:
- React may reuse the wrong component instance â showing stale state from item A in item B's position
- Animations break â React thinks two items are the same element and doesn't animate the new one
- Form inputs show wrong values â input state gets associated with the wrong key
// This LOOKS fine but causes subtle bugs with duplicate keys
{items.map(item => (
<PostCard key={item.id} post={item} />
))}If two items share item.id, React sees them as the same element. The second <PostCard> reuses the first one's fiber (internal state), including any local state like "liked" or "expanded."
The One-Liner That Impresses: "Duplicate keys aren't just a warning â they cause React to reuse the wrong component instance, leading to stale state, broken animations, and inputs showing the wrong values. The fix is preventing duplicate data from entering state in the first place."
Common Follow-Up Questions
"Is using array index as a key ever okay?"
"Only when three conditions are all true: the list is static (no reordering, inserting, or deleting), items have no state (no inputs, no expanded/collapsed), and there's no unique ID available. In practice, this is rare â API data almost always has IDs."
"How would you debug this in production?"
"Check the network tab for overlapping requests returning the same data. Add logging to the merge function to detect duplicates. The root cause is usually a race condition â two fetches with the same cursor, or offset-based pagination where new items shifted the pages."
Red Flags in Your Answer
- Saying "just add a unique key" without addressing WHY duplicates exist in the data
- Not mentioning race conditions as the root cause
- Using
Math.random()orDate.now()as keys (defeats the purpose of stable identity) - Not knowing that duplicate keys cause state bugs, not just console warnings
Q25: Why Does My Component Re-render Multiple Times?
Interview Question: "My component renders 4 times when I expected it to render once. What's going on?"
Think of it like a doorbell. You expect it to ring once when someone presses it. But if the wiring is bad, it might ring multiple times. In React, "unexpected re-renders" have specific, predictable causes â and understanding them is what separates debugging by guessing from debugging by reasoning.
The Top 5 Reasons
Reason 1: Parent re-rendered
The most common and most misunderstood cause. When a parent component re-renders, all its children re-render too â even if their props haven't changed.
function Parent() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<ExpensiveChild /> {/* Re-renders every time count changes! */}
</div>
);
}
function ExpensiveChild() {
console.log('ExpensiveChild rendered');
return <div>I never change but I re-render anyway</div>;
}Fix: Wrap with React.memo if the re-render is expensive:
const ExpensiveChild = React.memo(function ExpensiveChild() {
console.log('ExpensiveChild rendered');
return <div>Now I only render once</div>;
});Reason 2: State update in the component itself
Every setState call triggers a re-render. If you set multiple state values, that's multiple re-renders (in React 17) or one batched re-render (in React 18+).
// React 17: 3 separate re-renders inside setTimeout
setTimeout(() => {
setA(1);
setB(2);
setC(3);
});
// React 18: 1 batched re-render (automatic batching)
setTimeout(() => {
setA(1);
setB(2);
setC(3);
});Reason 3: Context value changed
Every consumer of a Context re-renders when the provider's value changes â even if the consumer only reads one field from the value:
const AppContext = createContext({ theme: 'dark', user: null, count: 0 });
function ThemeDisplay() {
const { theme } = useContext(AppContext);
// Re-renders when count changes too! No selector support.
return <div>{theme}</div>;
}Reason 4: New object/array/function references in props
function Parent() {
return (
<Child
style={{ color: 'red' }}
items={[1, 2, 3]}
onClick={() => console.log('click')}
/>
);
}Every render creates NEW style, items, and onClick objects. Even with React.memo, the child re-renders because the props are technically different objects. Fix with useMemo and useCallback.
Reason 5: React Strict Mode (development only)
This is the one that catches people off guard. React Strict Mode intentionally double-invokes your component function to help find bugs:
// In development with StrictMode:
// Component renders â renders AGAIN â effect runs â cleanup â effect runs AGAINThis only happens in development. In production, components render once per update as expected.
How to Debug Re-renders
// Quick and dirty: useRef counter
function MyComponent(props) {
const renderCount = useRef(0);
renderCount.current++;
console.log(`MyComponent rendered ${renderCount.current} times`);
return <div>...</div>;
}For production debugging, use React DevTools Profiler â it shows exactly which components re-rendered and why (props changed, parent rendered, hooks changed).
The One-Liner That Impresses: "Every re-render has a cause: parent re-rendered, state changed, context changed, or new object references in props. In development, Strict Mode adds an extra render on purpose. The fix isn't 'memo everything' â it's understanding which re-renders are expensive and targeting only those."
Common Follow-Up Questions
"Should you memoize everything?"
"No. Memoization has a cost â React must compare all props on every render. For cheap components (a div with some text), the comparison is more expensive than just re-rendering. Only memoize components that are expensive to render or appear in large lists."
"How does React 18 batching affect this?"
"React 18 batches ALL state updates â including those in promises, timeouts, and native event handlers. This means fewer re-renders by default. You only need
flushSyncif you specifically need a state update to render immediately."
Red Flags in Your Answer
- Saying "React re-renders when props change" (it re-renders when the parent re-renders â regardless of props)
- Not knowing about Strict Mode double rendering
- Suggesting
React.memoon every component as a blanket fix - Blaming React for "extra renders" without understanding each cause
- Not knowing about React 18 automatic batching
Q31: Can useEffect Run Twice?
Interview Question: "I'm seeing my useEffect running twice on mount. Is that a bug?"
Think of it like a fire drill. The building is fine â but the fire department runs a drill to test if the alarm system works and everyone knows the exit routes. React Strict Mode does the same thing: it runs your effects twice in development to test if they clean up properly.
The Answer: Yes, In Development with Strict Mode
function UserProfile({ userId }: { userId: string }) {
useEffect(() => {
console.log('Fetching user...');
fetchUser(userId);
return () => {
console.log('Cleaning up...');
};
}, [userId]);
return <div>...</div>;
}In development with <StrictMode>, this logs:
Fetching user...
Cleaning up...
Fetching user...React deliberately mounts â unmounts â remounts your component. Why? To catch effects that don't clean up properly. If your app breaks on the second mount, it would also break when:
- The user navigates away and back
- A component suspends and re-mounts
- React re-uses a component in a future feature (like offscreen rendering)
Effects That Break Under Strict Mode (And How to Fix Them)
// â Broken: no cleanup, duplicates the subscription
useEffect(() => {
const ws = new WebSocket('wss://api.example.com');
ws.onmessage = (event) => setMessages(prev => [...prev, event.data]);
}, []);
// â
Fixed: cleanup closes the connection
useEffect(() => {
const ws = new WebSocket('wss://api.example.com');
ws.onmessage = (event) => setMessages(prev => [...prev, event.data]);
return () => ws.close();
}, []);// â Broken: fetches data but doesn't cancel on cleanup
useEffect(() => {
fetch(`/api/user/${id}`).then(r => r.json()).then(setUser);
}, [id]);
// â
Fixed: AbortController cancels the stale request
useEffect(() => {
const controller = new AbortController();
fetch(`/api/user/${id}`, { signal: controller.signal })
.then(r => r.json())
.then(setUser)
.catch(err => {
if (err.name !== 'AbortError') throw err;
});
return () => controller.abort();
}, [id]);The Rule
Every effect that creates something should destroy it in the cleanup function:
| Creates | Cleanup must |
|---|---|
Timer (setInterval) | clearInterval |
| Event listener | removeEventListener |
| WebSocket | ws.close() |
| Fetch request | controller.abort() |
| Subscription | unsubscribe() |
| DOM mutation | Revert the mutation |
The One-Liner That Impresses: "useEffect running twice in development is intentional â Strict Mode stress-tests your cleanup functions because any effect that breaks on re-mount would also break during navigation, Suspense boundaries, or future concurrent features."
Common Follow-Up Questions
"How do I prevent the double-fire in development?"
"You don't â and you shouldn't. The double-fire IS the test. If it causes problems, your effect has a cleanup bug. Fix the cleanup, not the Strict Mode behavior. Removing StrictMode just hides bugs that will surface in production."
"Does this happen in production?"
"No. Strict Mode only double-invokes effects in development. In production, effects run exactly once per mount. This is purely a development-time diagnostic."
Red Flags in Your Answer
- Saying "remove StrictMode to fix it" (this hides the bug instead of fixing it)
- Not mentioning cleanup functions as the solution
- Thinking the double-fire is a React bug
- Not knowing that it's development-only behavior
Q32: Is React Synchronous or Asynchronous?
Interview Question: "Is React synchronous or asynchronous?"
Think of it like a kitchen. A synchronous kitchen finishes one order completely before starting the next â even if order #1 is a 45-minute steak and order #2 is a glass of water. An asynchronous kitchen (React 18+) can pause the steak, serve the water, and resume the steak â keeping everyone happy.
The Answer: Both
Before React 18: Rendering was always synchronous. Once React started rendering a component tree, it had to finish before the browser could do anything else â no yielding, no interruptions.
React 18+: React has concurrent rendering â the ability to start rendering, pause mid-way to handle something urgent (like user input), then resume or restart.
function App() {
const [query, setQuery] = useState('');
const [isPending, startTransition] = useTransition();
function handleChange(value: string) {
setQuery(value);
startTransition(() => {
setFilteredResults(filterLargeList(value));
});
}
return (
<div>
<input value={query} onChange={e => handleChange(e.target.value)} />
{isPending ? <Spinner /> : <Results data={filteredResults} />}
</div>
);
}Here's what happens:
setQuery(value)â synchronous urgent update. React renders the input change immediately.startTransition(...)â asynchronous transition. React works on filtering in the background. If the user types again, React abandons the stale work and starts fresh.
State Updates Are Synchronous Calls, Asynchronous Effects
This trips up everyone:
function handleClick() {
setCount(1);
console.log(count); // Still 0! Not 1!
}setCount is a synchronous function call â it executes immediately and returns. But the state variable doesn't update until the next render. The new value is "scheduled," not applied instantly.
Think of it like dropping a letter in a mailbox. The act of dropping it is instant (synchronous), but the letter doesn't arrive until the next delivery (next render).
The Batching Angle
React 18 batches all state updates into a single re-render:
function handleClick() {
setA(1); // Doesn't render yet
setB(2); // Doesn't render yet
setC(3); // NOW React renders once with all three updates
}
// Even in async code (new in React 18):
async function handleSubmit() {
const data = await saveForm();
setStatus('saved');
setMessage(data.message);
// Still just one render!
}The Full Picture
| Aspect | Synchronous | Asynchronous |
|---|---|---|
| setState call | â Executes immediately | |
| State value update | â Applied on next render | |
| Rendering (pre-React 18) | â Runs to completion | |
| Rendering (React 18) | â Can be interrupted (transitions) | |
| Commit phase (DOM updates) | â Always synchronous | |
| useEffect | â Runs after paint |
The One-Liner That Impresses: "React is synchronous by default â rendering runs to completion and DOM updates happen in one pass. But React 18 introduced concurrent rendering, which lets React pause non-urgent work to keep the UI responsive, making it selectively asynchronous when you opt in with transitions."
Common Follow-Up Questions
"How do I read the updated state immediately?"
"You can't â that's by design. Use the functional updater form (
setCount(prev => prev + 1)) to reference the latest value, or useEffect to respond to state changes after render."
"What's the difference between concurrent mode and concurrent features?"
"Concurrent mode was an all-or-nothing opt-in that was eventually dropped. Concurrent features (useTransition, useDeferredValue) are opt-in per update â you choose which state changes are interruptible. Everything else is still synchronous."
Red Flags in Your Answer
- Saying "React is asynchronous" without nuance
- Saying "setState is async" (the call is sync â the effect is deferred)
- Not mentioning concurrent rendering / React 18
- Not knowing about automatic batching
- Confusing asynchronous state updates with JavaScript event loop async (they're different mechanisms)
Q33: Can Refs Cause Memory Leaks?
Interview Question: "Can refs cause memory leaks in React? Give me an example."
Think of it like a sticky note on your desk that says "remember to call John." Even after John moves away and changes his number, the sticky note is still there, taking up space and pointing to something that no longer exists. A ref that holds a reference to a removed DOM node or a large data structure is that sticky note â it prevents garbage collection.
The Answer: Yes, Absolutely
Refs persist across renders and don't participate in React's cleanup lifecycle. If a ref holds a reference to something that should be garbage collected, it won't be.
Example 1: Holding References to Removed DOM Nodes
function SliderComponent() {
const allSlidesRef = useRef<HTMLDivElement[]>([]);
return (
<div>
{slides.map((slide, i) => (
<div
key={slide.id}
ref={(el) => {
if (el) allSlidesRef.current[i] = el;
}}
>
{slide.content}
</div>
))}
</div>
);
}When slides are removed from the list, the DOM nodes are removed â but allSlidesRef.current still holds references to them. Those DOM nodes can't be garbage collected.
The fix: Clean up refs when elements are removed:
ref={(el) => {
if (el) {
allSlidesRef.current[i] = el;
} else {
allSlidesRef.current.splice(i, 1);
}
}}Or better â use React 19's cleanup return from ref callbacks:
ref={(el) => {
allSlidesRef.current[i] = el;
return () => { allSlidesRef.current[i] = null; };
}}Example 2: Holding References to Large Data in Unmounted Components
function DataVisualizer() {
const cachedData = useRef<Record<string, LargeDataset>>({});
useEffect(() => {
async function load() {
const data = await fetchMassiveDataset();
cachedData.current['main'] = data;
}
load();
}, []);
return <Chart data={cachedData.current['main']} />;
}Even after DataVisualizer unmounts, if anything holds a reference to the component's closure, cachedData keeps the massive dataset in memory.
The fix: Clear the ref on unmount:
useEffect(() => {
return () => {
cachedData.current = {};
};
}, []);Example 3: Event Listeners Referencing Stale Refs
function TrackingComponent() {
const elementRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleMouseMove(e: MouseEvent) {
if (elementRef.current) {
// This creates a closure over elementRef
}
}
window.addEventListener('mousemove', handleMouseMove);
// â Missing cleanup: listener persists after unmount,
// holding a reference to elementRef and its DOM node
}, []);
return <div ref={elementRef}>Track me</div>;
}The fix: Always clean up event listeners:
useEffect(() => {
function handleMouseMove(e: MouseEvent) { /* ... */ }
window.addEventListener('mousemove', handleMouseMove);
return () => window.removeEventListener('mousemove', handleMouseMove);
}, []);The Pattern: Refs and the Garbage Collector
Component mounts â ref.current = something
Component unmounts â ref still exists if closure references it
â ref.current still points to the old "something"
â "something" can't be garbage collected
â MEMORY LEAKThe garbage collector frees memory when nothing references an object. Refs break this by holding references that outlive their usefulness.
The Checklist to Prevent Ref Memory Leaks
| Check | How |
|---|---|
| DOM node refs | Use callback refs with cleanup, or null them on unmount |
| Data cache refs | Clear in useEffect cleanup |
| Timer refs | clearInterval / clearTimeout in cleanup |
| Event listener refs | removeEventListener in cleanup |
| WebSocket/subscription refs | Close/unsubscribe in cleanup |
The One-Liner That Impresses: "Refs are escape hatches from React's lifecycle â they persist across renders and don't auto-clean up. Any ref pointing to DOM nodes, large data, or subscriptions needs manual cleanup in useEffect's return function, or it becomes a memory leak that the garbage collector can't touch."
Common Follow-Up Questions
"How do you detect memory leaks in React?"
"Chrome DevTools Memory tab â take heap snapshots before and after navigation, then compare. Look for 'Detached DOM tree' entries. The Performance Monitor panel also shows real-time JS heap size. In CI, tools like
leakageor Playwright's memory profiling can automate detection."
"Do state variables cause memory leaks?"
"Not usually â React cleans up state when a component unmounts. But closures can prevent garbage collection if an effect captures state in a long-lived callback (like a global event listener) without cleaning up."
Red Flags in Your Answer
- Saying refs can't cause memory leaks because React manages them (React doesn't clean up ref values)
- Not mentioning useEffect cleanup as the primary defense
- Confusing React's component lifecycle with JavaScript garbage collection
- Not knowing how to detect memory leaks (DevTools Memory tab)
- Thinking unmounting a component automatically clears its refs (it doesn't clear the values â only the component instance)