Incorrect dependency array in useEffect, causing unexpected behavior

Loading

Mastering useEffect Dependency Arrays

The dependency array in useEffect is crucial for controlling when your effects run. Getting it wrong can lead to infinite loops, stale data, or effects that don’t run when needed.

Core Principles

  1. Including all dependencies: Any value from the component scope that’s used in the effect
  2. Omitting unnecessary dependencies: Values that don’t affect the effect’s behavior
  3. Stable references: Preventing unnecessary effect re-runs from unstable dependencies

Common Mistakes & Fixes

1. Missing Dependencies (Stale Closures)

// ❌ Missing count dependency
const [count, setCount] = useState(0);

useEffect(() => {
  const interval = setInterval(() => {
    console.log(count); // Always logs initial value
  }, 1000);
  return () => clearInterval(interval);
}, []); // Empty array

// ✅ Correct - includes count
useEffect(() => {
  const interval = setInterval(() => {
    console.log(count);
  }, 1000);
  return () => clearInterval(interval);
}, [count]);

2. Unstable Dependencies (Infinite Loops)

// ❌ New object/array every render causes infinite loop
const [user, setUser] = useState(null);
const userConfig = { userId: user?.id };

useEffect(() => {
  fetchWithConfig(userConfig);
}, [userConfig]); // userConfig changes every render

// ✅ Solutions:
// Option 1: Move object inside effect
useEffect(() => {
  const userConfig = { userId: user?.id };
  fetchWithConfig(userConfig);
}, [user?.id]);

// Option 2: Memoize with useMemo
const userConfig = useMemo(() => ({ userId: user?.id }), [user?.id]);
useEffect(() => {
  fetchWithConfig(userConfig);
}, [userConfig]);

3. Over-specifying Dependencies

// ❌ Unnecessary dependencies
const [data, setData] = useState([]);
const [filter, setFilter] = useState('');

useEffect(() => {
  console.log('Filter changed');
  // Only needs filter
}, [data, filter]); // data isn't used in effect

// ✅ Correct
useEffect(() => {
  console.log('Filter changed');
}, [filter]);

Advanced Patterns

1. Function Dependencies

// ❌ Problem - new function each render
const fetchUser = async () => {
  const res = await getUser(userId);
  setUser(res);
};

useEffect(() => {
  fetchUser();
}, [fetchUser]); // Infinite loop

// ✅ Solutions:
// Option 1: Move function inside effect
useEffect(() => {
  const fetchUser = async () => {
    const res = await getUser(userId);
    setUser(res);
  };
  fetchUser();
}, [userId]);

// Option 2: useCallback
const fetchUser = useCallback(async () => {
  const res = await getUser(userId);
  setUser(res);
}, [userId]);

useEffect(() => {
  fetchUser();
}, [fetchUser]);

2. Dynamic Dependencies

// Only run effect when condition is true
const [data, setData] = useState(null);
const shouldFetch = data === null;

useEffect(() => {
  if (shouldFetch) {
    fetchData().then(setData);
  }
}, [shouldFetch]); // Works because it's a primitive

Debugging Techniques

  1. Add effect logging:
   useEffect(() => {
     console.log('Effect ran with:', dep1, dep2);
     // Effect logic
   }, [dep1, dep2]);
  1. Use the React DevTools Profiler to identify unnecessary effect runs
  2. Enable exhaustive-deps ESLint rule to catch missing dependencies:
   // eslint-disable-next-line react-hooks/exhaustive-deps
   useEffect(() => {}, []); // Only override when absolutely necessary

Best Practices

  1. Follow the ESLint rules – they catch most dependency issues
  2. Keep effects small and focused – easier dependency management
  3. Consider custom hooks for complex effect logic
  4. Use useMemo/useCallback to stabilize references when needed
  5. Think about cleanup for every effect that needs it

Remember: The dependency array should include every value that the effect uses that could change between renders. When in doubt, trust the ESLint warnings – they’re almost always correct about missing dependencies.

Leave a Reply

Your email address will not be published. Required fields are marked *