A common React performance issue occurs when useEffect
dependencies are incorrectly specified, leading to:
- Excessive effect executions
- Infinite loops
- Unnecessary re-renders
Core Principles
1. The Dependency Array Should Reflect All Reactive Values
Every value used inside the effect that can change between renders must be included in the dependencies array.
// Correct
useEffect(() => {
document.title = `Hello ${name}`;
}, [name]); // name is correctly included
2. Empty Dependency Array Means “Run Once”
useEffect(() => {
// Runs only once on mount
initializeSomething();
}, []); // Empty array = no dependencies
Common Problems and Solutions
Problem 1: Missing Dependencies
// ❌ Dangerous - might miss updates
useEffect(() => {
fetchData(userId);
}, []); // Missing userId dependency
Solution:
// ✅ Correct
useEffect(() => {
fetchData(userId);
}, [userId]);
Problem 2: Unnecessary Dependencies Causing Excessive Runs
// ❌ Runs on every render because object reference changes
useEffect(() => {
doSomethingWith(config);
}, [config]); // config object changes reference often
Solutions:
Option 1: Memoize the dependency
const memoizedConfig = useMemo(() => config, [config.key1, config.key2]);
useEffect(() => {
doSomethingWith(memoizedConfig);
}, [memoizedConfig]);
Option 2: Use primitive values
useEffect(() => {
doSomethingWith(config.value);
}, [config.value]); // Only depends on primitive
Problem 3: Functions as Dependencies
// ❌ Will re-run if handleSubmit reference changes
useEffect(() => {
window.addEventListener('submit', handleSubmit);
return () => window.removeEventListener('submit', handleSubmit);
}, [handleSubmit]);
Solutions:
Option 1: Move function inside effect (if possible)
useEffect(() => {
const handleSubmit = () => { /* ... */ };
window.addEventListener('submit', handleSubmit);
return () => window.removeEventListener('submit', handleSubmit);
}, []); // No dependencies needed
Option 2: Memoize the function
const handleSubmit = useCallback(() => {
/* ... */
}, [dependency1, dependency2]);
useEffect(() => {
window.addEventListener('submit', handleSubmit);
return () => window.removeEventListener('submit', handleSubmit);
}, [handleSubmit]);
Problem 4: Infinite Loops
// ❌ Infinite loop - updates state then re-runs effect
useEffect(() => {
setCount(count + 1);
}, [count]);
Solutions:
Option 1: Use functional updates
useEffect(() => {
setCount(prev => prev + 1);
}, []); // No count dependency needed
Option 2: Add conditional logic
useEffect(() => {
if (count < MAX_COUNT) {
setCount(count + 1);
}
}, [count]);
Advanced Patterns
1. Effect Dependencies That Shouldn’t Trigger
useEffect(() => {
// Uses currentRef.current but shouldn't trigger effect
console.log(currentRef.current);
}, [/* what to put here? */]);
Solution: Omit refs from dependencies
useEffect(() => {
console.log(currentRef.current);
}, []); // Refs are mutable and shouldn't trigger effects
2. Using Previous Values
useEffect(() => {
if (value !== prevValue) {
// Do something
}
}, [value, prevValue]);
Better Solution: Use a custom hook
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}
function Component({ value }) {
const prevValue = usePrevious(value);
useEffect(() => {
if (value !== prevValue) {
// Do something
}
}, [value]); // Only need current value in dependencies
}
Best Practices
- Use the ESLint plugin –
eslint-plugin-react-hooks
will warn about missing dependencies - Separate concerns – Use multiple effects instead of one big effect
- Consider custom hooks – For complex effect logic that might be reused
- Clean up properly – Always return a cleanup function when needed
- Think about effect timing – Effects run after render, consider if you really need an effect
When to Break the Rules
In rare cases, you might intentionally omit dependencies:
- When you’re absolutely sure the effect should only run once
- When dealing with non-reactive values (like refs)
- When using functions that are truly constant (but consider
useCallback
instead)