A common React performance issue occurs when useEffect
dependencies are incorrectly specified, leading to:
- Excessive effect re-runs
- Infinite loops
- Stale closure problems
- Unnecessary re-renders
Core Problems
1. Over-specifying Dependencies
useEffect(() => {
// Only needs userId
fetchData(userId);
}, [userId, props, state]); // Unnecessary dependencies
2. Missing Necessary Dependencies
useEffect(() => {
// Missing userId dependency
fetchData(userId);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
3. New Object References Triggering Effects
useEffect(() => {
// Runs on every render because config is new object
initialize({ debug: true });
}, [{ debug: true }]);
Optimization Techniques
1. Minimal Dependencies Principle
Only include what the effect actually uses:
useEffect(() => {
fetchData(userId);
}, [userId]); // Only what's needed
2. Dependency Management Strategies
For functions:
const fetchData = useCallback(() => {
// implementation
}, [userId]); // Stable function reference
useEffect(() => {
fetchData();
}, [fetchData]);
For objects/arrays:
const config = useMemo(() => ({ debug: true }), []);
useEffect(() => {
initialize(config);
}, [config]);
3. Effect Event Pattern (React 18+)
import { useEffect, useEffectEvent } from 'react';
function ChatRoom({ roomId }) {
const onMessage = useEffectEvent(() => {
// Doesn't need to be in dependencies
});
useEffect(() => {
const connection = connect(roomId);
connection.on('message', onMessage);
return () => connection.disconnect();
}, [roomId]); // Only roomId needed
}
4. Functional State Updates
When only previous state is needed:
const [count, setCount] = useState(0);
// No need for count in dependencies
useEffect(() => {
const interval = setInterval(() => {
setCount(prev => prev + 1); // Functional update
}, 1000);
return () => clearInterval(interval);
}, []);
Common Pitfalls and Solutions
- Event handlers in effects:
// ❌ Creates new function each time
useEffect(() => {
const handleClick = () => { /* ... */ };
window.addEventListener('click', handleClick);
return () => window.removeEventListener('click', handleClick);
}, []); // Missing handleClick dependency
// ✅ Correct approach
useEffect(() => {
const handleClick = () => { /* ... */ };
window.addEventListener('click', handleClick);
return () => window.removeEventListener('click', handleClick);
}, []); // Stable function doesn't need dependency
- Async operations with dependencies:
useEffect(() => {
let isCurrent = true;
async function loadData() {
const data = await fetchData(userId);
if (isCurrent) setData(data);
}
loadData();
return () => { isCurrent = false; };
}, [userId]); // Proper cleanup with dependency
- Multiple state updates:
// ❌ Unnecessarily includes count
useEffect(() => {
setCount(count + 1);
setFlag(true);
}, [count]);
// ✅ Separate effects when possible
useEffect(() => {
setCount(prev => prev + 1);
}, []);
useEffect(() => {
setFlag(true);
}, []);
Debugging Dependency Issues
- React Hook ESLint Plugin
- Essential for catching missing dependencies
- Configure in eslintrc:
json "react-hooks/exhaustive-deps": "warn"
- Effect Logging
useEffect(() => {
console.log('Effect ran with:', { userId, props });
// effect logic
}, [userId, props]);
- Strict Mode Detection
- Helps identify effects that need cleanup
- Enabled by default in Create React App
Best Practices
- Keep effects small and focused – Split complex effects
- Move functions inside effects when they don’t need to be reused
- Use dependency arrays strictly – Don’t disable the linter without good reason
- Consider custom hooks for reusable effect logic
- Prefer derived state over effects when possible
- Clean up properly for subscriptions, timers, etc.