Explain useEffect as React’s external sync point through cleanup, dependency discipline, stale-closure debugging, and the common mistake of putting pure derived logic into effects.
Use this React interview question to rehearse a quick answer, common mistake, follow-up, and production pitfall.
useEffect() in React: syncing with the outside world (timing, dependencies, cleanup)Frontend interview answer
This React interview question tests whether you can explain useEffect in React: external sync, cleanup pitfalls, and stale-closure debugging, connect it to production trade-offs, and handle common follow-up questions.
- useEffect in React: external sync, cleanup pitfalls, and stale-closure debugging explanation without falling back to memorized docs wording
- Hooks and Effects reasoning, edge cases, and production failure modes
- How you would answer the most likely React interview follow-up
Production pitfalluseEffect() is for synchronizing React with something outside render: network, subscriptions, timers, browser APIs, or analytics. The common mistake is treating it like a general-purpose “run code after render” hook. That leads to stale-closure bugs, cleanup leaks, and debug sessions where derived state keeps drifting because effect logic should have stayed in render.
Boundary check
Event handlers respond to a user action right now (click, submit, keypress). Render derives pure values from current props/state. Effects are the third bucket: they synchronize with something outside React after commit. If a value is just computed UI state, keep it in render. If the code touches a subscription, timer, browser API, or network request, that is effect territory.
Concept | What it means | Why it matters |
|---|---|---|
Runs after commit | Effects run after React has committed updates to the DOM (they don’t run during render). | You avoid doing real-world work in render (render may run multiple times). |
Cleanup is part of the contract | An effect can return a cleanup function. | React runs cleanup before re-running the effect and on unmount (prevents leaks). |
Dependencies define when to re-sync | The dependency array tells React when your effect’s inputs changed. | Correct deps prevent stale values and accidental infinite loops. |
Dependency array rules (what actually happens)
React decides whether to re-run an effect by comparing dependency values with Object.is.
Dependency form | When it runs | Typical usage |
|---|---|---|
| Runs on mount; cleanup runs on unmount. | Set up/tear down a subscription, event listener, timer. |
| Runs on mount and whenever | Re-sync when specific inputs change (e.g., |
No array | Runs after every commit; cleanup runs before each re-run. | Rare; usually a smell (often missing deps). |
Edge cases: objects, arrays, and refs in deps
React compares deps with Object.is. If you create a new object/array/function in render, it is a new identity each render, so the effect re-runs every time. Fix by memoizing (useMemo/useCallback) or moving the value outside render. Refs are different: the ref object from useRef is stable, so adding ref to deps usually does nothing. Changes to ref.current do not trigger re-renders, so do not expect deps to react to it; use state or a callback ref when you need to respond to DOM changes.
import React from 'react';
export default function DocumentTitle({ count }) {
// Sync with a browser API (document title)
React.useEffect(() => {
document.title = `Count: ${count}`;
}, [count]);
return <div>Count: {count}</div>;
}
import React from 'react';
export function ResizeLogger() {
React.useEffect(() => {
const onResize = () => console.log('width:', window.innerWidth);
window.addEventListener('resize', onResize);
// Cleanup prevents leaks + duplicate listeners
return () => window.removeEventListener('resize', onResize);
}, []);
return <div>Resize the window</div>;
}
Worked subscription example
Subscriptions are the classic useEffect case because you must both start listening and stop listening when the external source changes or the component disappears.
import React from 'react';
export function ChatStatus({ roomId }) {
const [online, setOnline] = React.useState(false);
React.useEffect(() => {
const connection = createChatConnection(roomId);
connection.connect();
connection.on('presence', (nextOnline) => setOnline(nextOnline));
return () => {
connection.off('presence');
connection.disconnect();
};
}, [roomId]);
return <span>{online ? 'Online' : 'Offline'}</span>;
}
import React from 'react';
export function IntervalCounter() {
const [n, setN] = React.useState(0);
React.useEffect(() => {
const id = setInterval(() => {
// Use functional update to avoid stale state
setN((x) => x + 1);
}, 1000);
return () => clearInterval(id);
}, []);
return <div>{n}</div>;
}
You might not need an effect
If a value can be computed from props/state during render, storing it via useEffect usually creates extra renders, loops, and stale-state bugs. Effects are for synchronizing with external systems, not for moving pure logic out of render.
// ❌ Extra render + duplicated state
function SearchResults({ items, query }) {
const [visible, setVisible] = React.useState([]);
React.useEffect(() => {
setVisible(items.filter((item) => item.label.includes(query)));
}, [items, query]);
return <List items={visible} />;
}
// ✅ Pure derivation stays in render
function SearchResultsFixed({ items, query }) {
const visible = items.filter((item) => item.label.includes(query));
return <List items={visible} />;
}
Data fetching pattern (avoid stale results)
Fetching is a side effect. The common issue is: the component re-renders with a new userId while an older request is still in flight. You need a cancellation/ignore strategy.
Cleanup + dependency mistake side by side
The bug is usually not the fetch itself. The bug is forgetting that changing inputs means React must re-synchronize and clean up the old work first.
// ❌ Stale subscription: roomId changes, but this effect never re-subscribes
useEffect(() => {
const connection = createChatConnection(roomId);
connection.connect();
return () => connection.disconnect();
}, []);
// ✅ Re-sync when roomId changes
useEffect(() => {
const connection = createChatConnection(roomId);
connection.connect();
return () => connection.disconnect();
}, [roomId]);
import React from 'react';
export function UserProfile({ userId }) {
const [user, setUser] = React.useState(null);
const [error, setError] = React.useState(null);
React.useEffect(() => {
const controller = new AbortController();
(async () => {
try {
setError(null);
const res = await fetch(`/api/users/${userId}`, { signal: controller.signal });
if (!res.ok) throw new Error('Request failed');
const data = await res.json();
setUser(data);
} catch (e) {
if (controller.signal.aborted) return; // ignore abort
setError(e);
}
})();
return () => controller.abort();
}, [userId]);
if (error) return <div>Failed</div>;
if (!user) return <div>Loading...</div>;
return <div>{user.name}</div>;
}
Common pitfalls (what interviewers watch for)
Pitfall | What goes wrong | Fix |
|---|---|---|
Missing dependencies | Effect reads stale values/props because it captured an old closure. | Include every value used inside the effect in the dependency list, or restructure (move logic into effect, use refs for mutable non-render state). |
Infinite loop | Effect sets state that changes a dependency, which re-runs the effect forever. | Don’t use effects to derive state from state; compute derived values during render or use memoization. |
Async effect function directly | Writing | Wrap async work inside the effect and return a real cleanup function. |
Using useEffect for pure derivations | Extra renders + harder logic (state duplication). | If it can be computed from props/state, compute it in render (or |
useEffect vs useLayoutEffectuseEffect is the default. Use useLayoutEffect only when you must read layout or synchronously write to the DOM before the browser paints (measurement, scroll positioning). Overusing useLayoutEffect can hurt performance because it can block paint.
StrictMode note (dev)
In development with <React.StrictMode>, React may run an effect’s setup + cleanup twice on mount to surface unsafe side effects. This is why effects must be idempotent and clean up correctly.
useEffectis for synchronizing a component with external systems (network, subscriptions, timers, browser APIs) after React commits UI.- The dependency array defines when React should re-run the effect; cleanup runs before re-run and on unmount.
- Common bugs: missing deps (stale closures), effect-driven derived state (loops), and forgetting cleanup (leaks).
- Default to
useEffect; useuseLayoutEffectonly for layout-sensitive DOM work.
Use this as one explanation rep, then continue with the React interview questions cluster or a guided prep path.