EN/CH Mode
BRUCE_FE React Interview Notes - useEffect Execution Timing
Deep dive into React useEffect execution timing, order, and common issues. Master useEffect's operation in component lifecycle, cleanup function timing, and dependency array design to avoid infinite loops and execution skips.
EN/CH Mode

Lazy to read articles? Then watch videos!
Basic Execution Timing of useEffect
useEffect is a core React Hook used for handling side effects. Its execution timing is a crucial point in React's rendering process, and understanding when it runs is essential for using it correctly.
Basic execution timing is as follows:
- 1. After initial render: Executes after the component is mounted and appears on screen
- 2. After dependency changes: Executes after re-render caused by specified dependency changes
- 3. Before unmount: Cleanup function (returned function) executes before component unmount
function ExampleComponent() {
const [count, setCount] = useState(0);
// Basic usage demonstration
useEffect(() => {
console.log('Effect executed: Component mounted or count changed');
document.title = `Count: ${count}`;
// Cleanup function
return () => {
console.log('Cleanup executed: Before next effect run or unmount');
};
}, [count]); // Depend on count changes
return (
<button onClick={() => setCount(c => c + 1)}>
Increment ({count})
</button>
);
}React Render Cycle and useEffect Relationship
useEffect always executes after screen updates, this is its key characteristic:
React Render Process
1. Execute component function, calculate virtual DOM
2. Update actual DOM, screen repaint
3. Execute useEffect function
This design has important benefits:
- 1. Doesn't block UI rendering, users can see the screen faster
- 2. Can safely access updated DOM elements
// Simple example
function App() {
const [name, setName] = useState('');
// Executes after DOM update
useEffect(() => {
// Screen is updated, user has seen new content
document.title = name || 'React App';
}, [name]);
return <input value={name} onChange={e => setName(e.target.value)} />;
}⚠️ Important: useEffect is always executedasynchronously, it triggers after the browser completes painting and won't block UI updates.
Execution Order of Multiple useEffects
When a component has multiple useEffects, their execution order follows these rules:
- 1. Execute sequentially from top to bottom in the order they are declared
- 2. Each useEffect's cleanup function runs before the next execution of that effect
- 3. During unmount, cleanup functions execute in reverse order of useEffects
function MultipleEffectsExample() {
// Effects will execute in order (after mount)
useEffect(() => {
console.log('First effect');
return () => console.log('Cleanup first effect');
}, []);
useEffect(() => {
console.log('Second effect');
return () => console.log('Cleanup second effect');
}, []);
useEffect(() => {
console.log('Third effect');
return () => console.log('Cleanup third effect');
}, []);
// Log order during component unmount:
// "Cleanup third effect"
// "Cleanup second effect"
// "Cleanup first effect"
}Cleanup Function Execution Timing
The cleanup function (function returned by effect) has two execution scenarios:
- 1. Before next effect execution: When dependencies change, the previous effect's cleanup runs before re-executing the effect
- 2. During component unmount: Before the component is removed from DOM, the last effect's cleanup function executes
The main purpose of cleanup functions is to prevent memory leaks and cancel unnecessary subscriptions or operations:
function WindowWidthObserver() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
// Set up event listener
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
// Cleanup function - prevent memory leaks
return () => {
window.removeEventListener('resize', handleResize);
};
}, []); // Empty dependency array, only runs on mount and unmount
return <div>Window width: {width}px</div>;
}Cleanup Function Execution Order in Multiple Renders
Initial Render
Component Render
Execute Effect A
Dependency Change
Component Re-render
Execute Effect A Cleanup
Execute New Effect A
Component Unmount
Component About to Remove
Execute Effect A Cleanup
Component Removed from DOM
Dependency Array and Execution Timing
The dependency array (second parameter) determines when useEffect re-executes:
| Dependency Array | Execution Timing | Use Cases |
|---|---|---|
未提供 | Executes after every render | Need to synchronize something after every render |
[] | Only executes after mount and before unmount | Setup/cleanup operations that only need to run once |
[a, b] | Executes after mount and when a or b changes | Side effects that respond to specific data changes |
function DependencyExample({ userId, filter }) {
// 1. Executes after every render (no dependency array)
useEffect(() => {
console.log('Component rendered');
});
// 2. Only executes on mount and unmount (empty dependency array)
useEffect(() => {
console.log('Component mounted');
return () => console.log('Component will unmount');
}, []);
// 3. Executes when userId changes (specific dependency)
useEffect(() => {
console.log(`Fetching data for user ${userId}`);
fetchUserData(userId);
}, [userId]);
// 4. Executes when userId or filter changes (multiple dependencies)
useEffect(() => {
console.log(`Applying filter ${filter} for user ${userId}`);
fetchFilteredData(userId, filter);
}, [userId, filter]);
}Common useEffect Mistakes and Solutions
1. Infinite Loop
One of the most common mistakes is updating state in useEffect without properly setting up the dependency array:
// ❌ Wrong example - Infinite loop
function InfiniteLoopExample() {
const [count, setCount] = useState(0);
useEffect(() => {
// effect updates state, causing re-render, triggering effect again
setCount(count + 1);
}, [count]); // re-runs when count changes
return <div>{count}</div>;
}
// ✅ Fix method 1 - Use functional update, remove dependency
function FixedExample1() {
const [count, setCount] = useState(0);
useEffect(() => {
setCount(c => c + 1); // functional update doesn't need count dependency
}, []); // runs only once
return <div>{count}</div>;
}
// ✅ Fix method 2 - Condition control
function FixedExample2() {
const [count, setCount] = useState(0);
const [hasIncremented, setHasIncremented] = useState(false);
useEffect(() => {
if (!hasIncremented) {
setCount(count + 1);
setHasIncremented(true);
}
}, [count, hasIncremented]);
return <div>{count}</div>;
}2. Missing Dependencies
Another common mistake is not including all variables used in the effect as dependencies:
// ❌ Wrong example - Missing dependency
function MissingDependencyExample({ productId, userId }) {
const [data, setData] = useState(null);
useEffect(() => {
// effect uses productId but it's missing from dependencies
fetchProductData(productId, userId).then(setData);
}, [userId]); // only depends on userId
return <div>{/* Display data */}</div>;
}
// ✅ Fix method - Include all dependencies
function FixedDependencyExample({ productId, userId }) {
const [data, setData] = useState(null);
useEffect(() => {
fetchProductData(productId, userId).then(setData);
}, [productId, userId]); // include all dependencies
return <div>{/* Display data */}</div>;
}3. Asynchronous Issues in Side Effects
When handling asynchronous operations, race conditions may occur:
Race Condition Illustration
User quickly types search string:
Problem: Old request results overwrite newer ones, causing UI to display results inconsistent with user's latest input.
// ❌ Example with potential issues - Race condition
function SearchResults({ query }) {
const [results, setResults] = useState([]);
useEffect(() => {
fetchResults(query).then(data => {
// ⚠️ No check here if query is still the latest
setResults(data);
});
}, [query]);
return <ResultsList results={results} />;
}Using AbortController to Solve Race Conditions
How AbortController works:
When typing "a"
Create controller A
Send request A
When typing "ab"
Execute cleanup function
controller A.abort()
→ Request A cancelled ✓
Create controller B
Send request B
Result
Only request B completes
Show results for "ab"
→ Matches latest input ✓
Benefits: Automatically cancels stale requests, prevents memory leaks, and ensures UI always shows results from the latest request.
// ✅ Method: Using AbortController (cleaner)
function FixedSearchResults2({ query }) {
const [results, setResults] = useState([]);
useEffect(() => {
const controller = new AbortController();
fetchResults(query, { signal: controller.signal })
.then(data => {
setResults(data);
})
.catch(err => {
if (err.name !== 'AbortError') {
console.error('Fetch failed:', err);
}
});
return () => {
controller.abort(); // Abort previous request
};
}, [query]);
return <ResultsList results={results} />;
}Handling useEffect to Avoid Memory Leaks
Incorrect useEffect cleanup can lead to memory leaks, especially when handling subscriptions, timers, event listeners, and other resources. Here are common memory leak scenarios and solutions:
Common Sources of Memory Leaks
- Uncleaned event listeners
- Uncancelled timers (setTimeout/setInterval)
- Unclosed network connections or subscriptions
- Unreleased DOM references
The correct cleanup patterns are as follows:
// Timer cleanup example
useEffect(() => {
const timer = setTimeout(() => {
console.log('This is a timer');
}, 1000);
// Cleanup function
return () => {
clearTimeout(timer); // Clean up timer
};
}, []);
// Event listener cleanup example
useEffect(() => {
const handleResize = () => {
console.log('Window size changed');
};
window.addEventListener('resize', handleResize);
// Cleanup function
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);Impact of Memory Leaks
Uncleaned Component: Maintains active connections even after component unmount
Component unmounted
↓
Listeners/timers still running
↓
Memory cannot be released
↓
May cause app crashes
Properly Cleaned Component: Releases all resources on unmount
Component about to unmount
↓
Execute cleanup function
↓
Release all resources
↓
Memory properly reclaimed
A useful technique for detecting memory leaks is to add warning logs during development:
function DataFetcher() {
useEffect(() => {
let isMounted = true;
fetchData().then(data => {
// Prevent setting state on unmounted component
if (isMounted) {
setData(data);
}
});
return () => {
isMounted = false;
console.log('DataFetcher resources cleaned up'); // Development check log
};
}, []);
// ...
}💡 Tip: Using React 18's Strict Mode can help identify potential memory leaks as it intentionally double-mounts and unmounts components, making cleanup issues easier to spot.
🔥 Common Interview Questions
(1) When does useEffect execute, when does its cleanup function run, and what is its purpose?
Answer: useEffect executes after each render (after DOM updates). Based on the dependency array:
- No dependency array: Executes after every render
- Empty dependency array
[]: Only executes after initial render - Array with dependencies: Executes after initial render and when dependencies change
useEffect Execution Flow
1. 元件渲染
2. 畫面更新
3. 執行useEffect
Cleanup Function Execution Timing:
- Before the next effect execution (when dependencies change)
- During component unmount (before removal from DOM)
Cleanup Function Purpose:
- Prevent memory leaks (cancel subscriptions, clear timers)
- Cancel ongoing API requests
- Remove event listeners
- Avoid updating state on unmounted components
useEffect(() => {
const timer = setTimeout(() => {...}, 1000);
return () => {
clearTimeout(timer); // Cleanup timer
};
}, [dependency]);(2) How to handle asynchronous operations in useEffect?
Answer: Key points for handling asynchronous operations:
- Use flag variables to prevent updating state on unmounted components
- Handle errors properly
- Handle race conditions (use only the latest result when multiple requests are made)
function FixedSearchResults2({ query }) {
const [results, setResults] = useState([]);
useEffect(() => {
const controller = new AbortController();
fetchResults(query, { signal: controller.signal })
.then(data => {
setResults(data);
})
.catch(err => {
if (err.name !== 'AbortError') {
console.error('Fetch failed:', err);
}
});
return () => {
controller.abort(); // Abort previous request
};
}, [query]);
return <ResultsList results={results} />;
}Handling Race Conditions
1. User searches 'A'
→ Send request A
2. User quickly searches 'B'
→ Cancel request A
→ Send request B
3. Only show results for B
→ Prevent old data override
(3) How to handle memory leaks in useEffect
Answer: Main methods to prevent memory leaks:
- Use cleanup functions to release resources
- Cancel incomplete asynchronous operations
- Remove event listeners and subscriptions
- Use AbortController to cancel fetch requests
useEffect(() => {
// 1. Event listener
window.addEventListener('resize', handleResize);
// 2. Timer
const interval = setInterval(tick, 1000);
// 3. Use AbortController to cancel fetch
const controller = new AbortController();
fetch(url, { signal: controller.signal });
// Cleanup function
return () => {
window.removeEventListener('resize', handleResize);
clearInterval(interval);
controller.abort();
};
}, []);Common Sources of Memory Leaks
Uncleaned Timers
setTimeout, setInterval
Unremoved Event Listeners
addEventListener
Uncancelled Network Requests
fetch, axios
Uncancelled Subscriptions
WebSocket, Observable
Using React 18 Strict Mode can help identify memory leaks as it double-mounts and unmounts components, making cleanup issues easier to spot.