useEffect: Dependencies, Cleanup, and Common Traps
When effects run, how dependency arrays work, reference stability traps, and avoiding stale closures.
On this page
30‑second interview answer
useEffect lets you run side effects after React commits updates to the DOM—things like data fetching, subscriptions, timers, and manual DOM work. The dependency array controls when the effect runs. Cleanup runs before the next effect (and on unmount) to avoid leaks. Most bugs come from incorrect dependencies and stale closures.
When does useEffect run? (The React Lifecycle Model)
Understanding the exact sequence of React phases separates junior developers from senior engineers:
[ Render Phase ] React calls your component to calculate the Virtual DOM.
│ (Must be pure: NO DOM mutation, NO side effects)
▼
[ Commit Phase ] React applies diffs to the real browser DOM.
│
▼
[ Layout Phase ] useLayoutEffect runs synchronously.
│ (DOM updated, but screen not yet painted; blocks paint)
▼
[ Browser Paint ] Browser paints pixels to the screen.
│ (User visually sees the updated UI)
▼
[ Passive Effects ] useEffect runs asynchronously after paint.
(Data fetching, analytics, non-blocking subscriptions)Next Re-render Execution Flow:
- State changes → Component re-renders with new props/state.
- Commit phase updates DOM and browser paints.
- Previous effect cleanup function runs (using old closed-over values).
- Current effect function runs (using new closed-over values).
Component Unmount Flow:
- Final effect cleanup function executes before the component is detached from the DOM tree.
useLayoutEffect vs useEffect
useLayoutEffect: Runs synchronously immediately after DOM mutation but before paint. Use ONLY when measuring layout (e.g. tooltip position, scroll clamping) to prevent visual flicker.useEffect: Runs asynchronously after paint. Always default touseEffectto avoid blocking browser rendering.
Dependency array: what it really means
React compares each dependency with its previous value using Object.is(prevDep, nextDep) (shallow reference equality).
1) No dependency array
useEffect(() => {
// Runs after EVERY commit/render
});2) Empty dependency array []
useEffect(() => {
// Runs ONCE after initial mount
return () => {
// Cleanup runs ONCE on unmount
};
}, []);3) With dependencies
useEffect(() => {
// Runs on mount AND whenever userId or token changes via Object.is comparison
}, [userId, token]);Golden Rule: Your dependencies array must include every reactive value (props, state, and functions derived from them) read inside the effect callback.
The Object & Function Reference Trap (Infinite Re-render Loops)
Because React checks dependencies with Object.is, non-primitive data types (objects, arrays, functions) create new memory references on every render:
function UserProfile({ userId }) {
const [data, setData] = useState(null);
// ❌ BAD: options is created as a NEW object reference on every render!
const options = { timeout: 3000 };
useEffect(() => {
fetchUserData(userId, options).then(setData);
// When setData updates state -> component re-renders -> new options object
// -> Object.is(prevOptions, nextOptions) is false -> Effect runs again -> INFINITE LOOP!
}, [userId, options]);
}How to fix reference stability traps:
- Move static objects/functions outside the component:
const DEFAULT_OPTIONS = { timeout: 3000 }; - Move dependencies inside the effect:
useEffect(() => { const options = { timeout: 3000 }; fetchUserData(userId, options).then(setData); }, [userId]); - Decompose to primitive dependencies:
Instead of depending on an entire object
[user], depend on the primitive properties you actually use:[user.id, user.role]. - Memoize with
useMemooruseCallbackif the object or function must be created inside the component body and passed across multiple hooks or children.
Cleanup (critical for interviews)
Cleanup runs:
- before the effect re-runs (cleaning up previous execution)
- when the component unmounts
Typical patterns:
Subscriptions
useEffect(() => {
const unsub = store.subscribe(() => {
// ...
});
return () => unsub();
}, []);Timers
useEffect(() => {
const id = setInterval(() => {
// ...
}, 1000);
return () => clearInterval(id);
}, []);The most common bug: stale closures
This happens when your effect “captures” old state/props because dependencies were omitted.
Example:
function Example() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log(count); // ❌ Always prints 0 because it closed over the initial render's count!
}, 1000);
return () => clearInterval(id);
}, []); // ❌ count missing from dependencies
}Fix 1: include dependencies
useEffect(() => {
const id = setInterval(() => console.log(count), 1000);
return () => clearInterval(id);
}, [count]); // Re-establishes interval each time count changesFix 2: use functional state updates (Preferred for counters/accumulators)
useEffect(() => {
const id = setInterval(() => {
setCount((prevCount) => prevCount + 1); // Reads latest state at execution time!
}, 1000);
return () => clearInterval(id);
}, []); // Safe with empty dependency arrayFix 3: store mutable latest value in a ref
const latestCount = useRef(count);
useEffect(() => {
latestCount.current = count;
}); // Syncs ref on every render
useEffect(() => {
const id = setInterval(() => {
console.log(latestCount.current); // Reads current ref value without re-triggering effect
}, 1000);
return () => clearInterval(id);
}, []);Fetching data safely & avoiding race conditions
In real applications, network responses may arrive out of order (e.g. user switches tab from ID 1 to ID 2, but request 1 resolves after request 2).
Production Pattern: AbortController
useEffect(() => {
const controller = new AbortController();
async function loadData() {
try {
const res = await fetch(`/api/user/${userId}`, { signal: controller.signal });
const user = await res.json();
setUser(user);
} catch (err) {
if (err.name !== 'AbortError') {
setError(err.message);
}
}
}
loadData();
// Abort pending request if userId changes or component unmounts
return () => controller.abort();
}, [userId]);React Strict Mode (why effects run twice in dev)
In development (<React.StrictMode>), React mounts, unmounts, and re-mounts every component:
Component mounts→Effect runsEffect cleanup runsEffect runs again
This intentional double-invocation verifies that your effect is idempotent and that cleanups properly mirror setup logic (e.g. unsubscribing from event listeners, clearing timers, resetting connections).
Senior Interview Puzzle: Parent vs Child Lifecycle Execution Order
Question: Given the following parent and child components, what is the exact output sequence in the console on initial mount?
function Child() {
console.log('1: Child Render');
useEffect(() => {
console.log('2: Child Effect');
return () => console.log('3: Child Cleanup');
}, []);
return <div>Child</div>;
}
function Parent() {
console.log('4: Parent Render');
useEffect(() => {
console.log('5: Parent Effect');
return () => console.log('6: Parent Cleanup');
}, []);
return <Child />;
}Step-by-Step Trace:
- Render Phase (Top-Down):
- React calls
Parent: prints4: Parent Render. - JSX encounters
<Child />, React callsChild: prints1: Child Render.
- React calls
- Commit Phase:
- React attaches Child and Parent DOM nodes.
- Passive Effects Phase (Bottom-Up):
- React flushes child effects before parent effects so that when the parent effect runs, all children are fully mounted and synchronized.
- Child effect executes: prints
2: Child Effect. - Parent effect executes: prints
5: Parent Effect.
Final output on initial mount:
4: Parent Render → 1: Child Render → 2: Child Effect → 5: Parent Effect.
Summary checklist
- I can explain the distinction between Render, Layout (
useLayoutEffect), and Paint (useEffect). - I know why omitting dependencies causes stale closures and how functional updates or refs resolve it.
- I understand how
Object.isreference equality creates infinite re-render loops with objects/functions. - I can handle asynchronous race conditions using
AbortController. - I can explain why child effects execute before parent effects on mount.
Authoritative Standards & References
- React.dev: Synchronizing with Effects
- React.dev: You Might Not Need an Effect
- React.dev: Separating Events from Effects
Written by Rajesh Dhiman at Eunix Tech for the Frontend Interview preparation track.
Looking for structured, offline interview prep?
Free online lessons • Portable Amazon Kindle / Paperback
Frontend Interview Guide (2025 Edition)
By Rajesh Dhiman
Comprehensive preparation covering React internals, Web Performance, CSS architecture, and system design questions.
JavaScript Interview Guide
By Rajesh Dhiman
Deep dive into the Event Loop, microtask draining, closures, prototypal inheritance, and asynchronous execution patterns.
Note: All lessons on Frontend Interview remain completely free to read online. As an Amazon Associate, we may earn from qualifying purchases.
Written by Rajesh Dhiman
Authored by Rajesh Dhiman and engineered at Eunix Tech. Focused on deep-dive frontend engineering, JavaScript runtime internals, React architecture, and web performance.