useEffect Dependency Arrays: Preventing Infinite Loop Hell

·frontend-explore

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

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
MetricBeforeAfter
Infinite loops12/week0
Unnecessary effect runs1000s/session&#x3C;10
API calls (wasted)450/min2/min
Browser freezes8/week0

Lessons Learned

  1. Primitives are Stable: Use primitive values in deps when possible
  2. useMemo for Objects: Memoize objects used in dependencies
  3. useCallback for Functions: Memoize functions used in dependencies
  4. Trust the Linter: react-hooks/exhaustive-deps catches most issues
  5. Debug Tool: Create custom hook to log dependency changes