Explain what useEffect is actually for (synchronizing with external systems), when it runs (after commit), how dependencies control re-running, and how cleanup prevents leaks. Cover common patterns (fetching, subscriptions, timers) and common pitfalls (stale closures, infinite loops, derived state in effects).
useEffect() in React: syncing with the outside world (timing, dependencies, cleanup)
Use guided tracks for structured prep, then practice company-specific question sets when you want targeted interview coverage.
Core ideauseEffect() is React’s API for side effects: code that must run outside rendering to keep your component synchronized with something external (network, subscriptions, browser APIs, timers, logging/analytics).
Key mental model: render computes UI (pure), then React commits DOM changes, then useEffect runs to “sync with the world.”
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>;
}
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>;
}
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.
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.