← Back

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

| 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

  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