useEffect Dependency Arrays: Preventing Infinite Loop Hell
Key Takeaway
Our useEffect hooks created infinite render loops by missing dependencies or including objects that recreated every render. Understanding dependency array rules and using useMemo/useCallback eliminated all infinite loops and improved app stability.
The Problem
function ImageList() {
const [images, setImages] = useState([]);
// Infinite loop! filters recreated every render
const filters = { status: 'active' };
useEffect(() => {
fetchImages(filters).then(setImages);
}, [filters]); // filters is new object every render → infinite loop!
}
Issues: Browser freezes, API rate limit exceeded, memory leaks, app crashes.
The Solution
function ImageList() {
const [images, setImages] = useState([]);
// Solution 1: Move primitive values into dependency array
const status = 'active';
useEffect(() => {
fetchImages({ status }).then(setImages);
}, [status]); // Primitive value - stable reference
// Solution 2: useMemo for objects
const filters = useMemo(() => ({
status: 'active',
type: 'wsi'
}), []); // Empty deps = created once
useEffect(() => {
fetchImages(filters).then(setImages);
}, [filters]); // filters stable now
// Solution 3: useCallback for functions
const handleFetch = useCallback(async () => {
const data = await fetchImages({ status: 'active' });
setImages(data);
}, []); // Function created once
useEffect(() => {
handleFetch();
}, [handleFetch]);
}
Common patterns:
// ✅ GOOD: Primitives in deps
useEffect(() => {
fetchData(userId);
}, [userId]);
// ✅ GOOD: Stable object from useMemo
const config = useMemo(() => ({ url, timeout }), [url, timeout]);
useEffect(() => {
fetch(config);
}, [config]);
// ✅ GOOD: Function from useCallback
const loadData = useCallback(() => {
fetchData(params);
}, [params]);
useEffect(() => {
loadData();
}, [loadData]);
// ❌ BAD: Object literal in deps
useEffect(() => {
fetch({ url, timeout });
}, [{ url, timeout }]); // New object every time!
// ❌ BAD: Inline function in deps
useEffect(() => {
doSomething();
}, [() => doSomething()]); // New function every time!
// ❌ BAD: Missing dependencies
useEffect(() => {
console.log(userId);
}, []); // userId missing from deps!
Implementation Details
ESLint Rule Enforcement
// .eslintrc.json
{
"rules": {
"react-hooks/exhaustive-deps": "error"
}
}
Debugging Infinite Loops
function useDebugEffect(effect: EffectCallback, deps: DependencyList, name: string) {
const previousDeps = useRef<DependencyList>();
useEffect(() => {
if (previousDeps.current) {
const changedDeps = deps.map((dep, i) => ({
index: i,
previous: previousDeps.current![i],
current: dep,
changed: dep !== previousDeps.current![i]
})).filter(d => d.changed);
if (changedDeps.length > 0) {
console.log(`${name} effect triggered. Changed deps:`, changedDeps);
}
}
previousDeps.current = deps;
return effect();
}, deps);
}
// Usage
useDebugEffect(() => {
fetchData();
}, [config], 'fetchData');
Impact
| Metric | Before | After | |--------|--------|-------| | Infinite loops | 12/week | 0 | | Unnecessary effect runs | 1000s/session | <10 | | API calls (wasted) | 450/min | 2/min | | Browser freezes | 8/week | 0 |
Lessons Learned
- Primitives are Stable: Use primitive values in deps when possible
- useMemo for Objects: Memoize objects used in dependencies
- useCallback for Functions: Memoize functions used in dependencies
- Trust the Linter: react-hooks/exhaustive-deps catches most issues
- Debug Tool: Create custom hook to log dependency changes