AdSense Leaderboard

Google AdSense Placeholder

HEADER SLOT

useEffect Dependency Array: Deep Mechanics Explained

Last updated:
useEffect Dependency Array: Deep Mechanics Explained

Master useEffect dependencies in React. Learn when effects run, how React tracks changes, common pitfalls, and the 3 dependency exceptions that trip up developers.

# useEffect Dependency Array: Deep Mechanics Explained

The dependency array in useEffect is one of React's most misunderstood features. Developers constantly run into bugs where effects fire at the wrong time, or worse, create infinite loops that crash their apps. The frustration usually stems from not understanding why the array exists or what React actually does with it.

Here's the thing: the dependency array isn't just "here's when to run this"—it's a communication channel between you and React's scheduler. When you pass an array, you're making a promise to React about which values the effect depends on. If you break that promise (by omitting a dependency or including one incorrectly), React can't help you track what changed. The effect either runs too often, too rarely, or not at all when it should.

In this guide, we'll dig into the actual mechanics of how React evaluates dependencies, examine the three exceptions everyone gets wrong, and show you patterns that prevent 90% of useEffect bugs in production code.

# Table of Contents

  1. The Three Dependency Array Modes
  2. How React Tracks Dependencies
  3. The Three Exceptions That Trip Everyone Up
  4. Common Pitfalls and How to Avoid Them
  5. Practical Application: Data Fetching Pattern
  6. FAQ

# The Three Dependency Array Modes

The dependency array fundamentally changes the execution behavior of useEffect. There are exactly three ways to use it, and each has distinct implications:

# No Dependency Array (Runs Every Render)

typescript
useEffect(() => {
  // This runs after EVERY component render
  console.log('Runs on every render');
});
javascript
useEffect(() => {
  // This runs after EVERY component render
  console.log('Runs on every render');
});

When you omit the dependency array entirely, React executes the effect function after every component render. This is rarely what you want because it easily creates performance problems. If your effect triggers a state update (like setCount), you'll create an infinite loop: render → effect runs → state updates → render → effect runs again.

This pattern is almost never intentional, and linters will warn you if you forget the array.

# Empty Dependency Array (Runs Once on Mount)

typescript
useEffect(() => {
  // This runs exactly ONCE, right after initial render
  console.log('Runs only once');
}, []);
javascript
useEffect(() => {
  // This runs exactly ONCE, right after initial render
  console.log('Runs only once');
}, []);

An empty array tells React: "This effect has zero dependencies. Don't watch anything. Just run once when the component first mounts." This is perfect for initialization logic like fetching data for the first time, subscribing to a socket connection, or starting a timer.

The key insight: this is different from omitting the array. You're explicitly telling React that there's nothing to watch, so it should never re-run.

# Populated Dependency Array (Runs When Dependencies Change)

typescript
useEffect(() => {
  // This runs whenever 'count' or 'userId' changes
  console.log(`Count is ${count}, User is ${userId}`);
}, [count, userId]);
javascript
useEffect(() => {
  // This runs whenever 'count' or 'userId' changes
  console.log(`Count is ${count}, User is ${userId}`);
}, [count, userId]);

When you provide values in the array, React watches those specific values. If any of them change between renders, the effect re-runs. The comparison is done using Object.is() (which is like strict equality ===, but treats NaN as equal to NaN).

This is the most common pattern and the one that causes the most confusion because the rules for what to include aren't always obvious.

# How React Tracks Dependencies

React uses a surprisingly simple mechanism to track dependencies. After each render, React compares the new dependency array with the previous one, element by element, using strict equality.

Let's trace through an example:

typescript
function UserProfile({ userId }: { userId: number }) {
  const [user, setUser] = useState(null);
  
  useEffect(() => {
    fetchUser(userId).then(data => setUser(data));
  }, [userId]);  // ← Dependency array
  
  return <div>{user?.name}</div>;
}
javascript
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  
  useEffect(() => {
    fetchUser(userId).then(data => setUser(data));
  }, [userId]);  // ← Dependency array
  
  return <div>{user?.name}</div>;
}

Here's what happens internally:

  1. First render: userId = 5. React stores [5] as the previous dependencies.
  2. Effect runs: fetchUser(5) executes, user data loads.
  3. Second render: userId = 5. React compares [5] (current) with [5] (previous). They're identical, so effect doesn't run.
  4. Third render: userId = 10. React compares [10] (current) with [5] (previous). They differ, so effect runs again.

This is where objects and functions trip people up. Unlike primitives, objects are compared by reference:

typescript
function ItemList() {
  const [items, setItems] = useState([]);
  
  // ❌ PROBLEM: Object is recreated every render
  const config = { apiUrl: 'https://api.example.com' };
  
  useEffect(() => {
    fetchItems(config).then(data => setItems(data));
  }, [config]);  // Re-runs every render, not just when apiUrl changes!
}
javascript
function ItemList() {
  const [items, setItems] = useState([]);
  
  // ❌ PROBLEM: Object is recreated every render
  const config = { apiUrl: 'https://api.example.com' };
  
  useEffect(() => {
    fetchItems(config).then(data => setItems(data));
  }, [config]);  // Re-runs every render, not just when apiUrl changes!
}

Even though config has the same values every time, it's a different object, so Object.is(config, prevConfig) returns false. The effect re-runs unnecessarily.

The fix is either to move the object outside the component or use destructuring to extract the specific value you care about:

typescript
function ItemList() {
  const [items, setItems] = useState([]);
  
  // ✅ BETTER: Extract the specific string value
  const apiUrl = 'https://api.example.com';
  
  useEffect(() => {
    fetchItems(apiUrl).then(data => setItems(data));
  }, [apiUrl]);
}
javascript
function ItemList() {
  const [items, setItems] = useState([]);
  
  // ✅ BETTER: Extract the specific string value
  const apiUrl = 'https://api.example.com';
  
  useEffect(() => {
    fetchItems(apiUrl).then(data => setItems(data));
  }, [apiUrl]);
}

# The Three Exceptions That Trip Everyone Up

Here's where most developers get confused. The rule is: "add everything you use in the effect to the dependency array." But there are three explicit exceptions where you should not add something:

# Exception 1: Values Defined Inside the Effect

If a variable is created and used only inside the effect function, don't add it as a dependency:

typescript
useEffect(() => {
  // ✅ CORRECT: fetchedData is only used here, not defined outside
  const response = fetch('/api/users');
  // Don't add 'response' to dependencies
}, []);
javascript
useEffect(() => {
  // ✅ CORRECT: fetchedData is only used here, not defined outside
  const response = fetch('/api/users');
  // Don't add 'response' to dependencies
}, []);

Why? Because it's a local variable created fresh each time the effect runs. It can't be "compared" across renders because it didn't exist in the previous render.

# Exception 2: Functions and Values from Outside the Component

External APIs, utilities, or constants defined outside your component function don't need to be dependencies:

typescript
// Defined OUTSIDE component
async function fetchUser(id) {
  const response = await fetch(`/api/users/${id}`);
  return response.json();
}

function UserCard({ userId }: { userId: number }) {
  const [user, setUser] = useState(null);
  
  useEffect(() => {
    fetchUser(userId).then(setUser);
    // ❌ DON'T add 'fetchUser' as dependency—it's external
    // ✅ DO add 'userId'—it's a prop that can change
  }, [userId]);
  
  return <div>{user?.name}</div>;
}
javascript
// Defined OUTSIDE component
async function fetchUser(id) {
  const response = await fetch(`/api/users/${id}`);
  return response.json();
}

function UserCard({ userId }) {
  const [user, setUser] = useState(null);
  
  useEffect(() => {
    fetchUser(userId).then(setUser);
    // ❌ DON'T add 'fetchUser' as dependency—it's external
    // ✅ DO add 'userId'—it's a prop that can change
  }, [userId]);
  
  return <div>{user?.name}</div>;
}

The function never changes (it's defined once at the module level), so watching it is pointless. React knows this, and linters know this too.

# Exception 3: State Setter Functions from useState

The setter function returned by useState is guaranteed by React to never change:

typescript
function Counter() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    const timer = setInterval(() => {
      setCount(prev => prev + 1);  // ← Always the same function
    }, 1000);
    
    return () => clearInterval(timer);
    // ❌ DON'T add 'setCount' as dependency
  }, []);  // Empty array is fine
}
javascript
function Counter() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    const timer = setInterval(() => {
      setCount(prev => prev + 1);  // ← Always the same function
    }, 1000);
    
    return () => clearInterval(timer);
    // ❌ DON'T add 'setCount' as dependency
  }, []);  // Empty array is fine
}

React deliberately ensures that the function identity never changes across renders, specifically to prevent unnecessary effect re-runs. This is an optimization that React handles for you.

# Common Pitfalls and How to Avoid Them

# Pitfall 1: Function Dependencies Causing Loops

When you define a function inside the component and use it in an effect, React creates a new function object every render:

typescript
function SearchResults() {
  const [results, setResults] = useState([]);
  
  function performSearch(query: string) {
    return fetch(`/api/search?q=${query}`).then(r => r.json());
  }
  
  useEffect(() => {
    performSearch('react');
    // ❌ PROBLEM: performSearch is recreated every render
    // If you add it as dependency, effect runs every render
  }, [performSearch]);  // ← Infinite loop!
}
javascript
function SearchResults() {
  const [results, setResults] = useState([]);
  
  function performSearch(query) {
    return fetch(`/api/search?q=${query}`).then(r => r.json());
  }
  
  useEffect(() => {
    performSearch('react');
    // ❌ PROBLEM: performSearch is recreated every render
    // If you add it as dependency, effect runs every render
  }, [performSearch]);  // ← Infinite loop!
}

The fix is to either move the function outside the component or wrap it with useCallback:

typescript
import { useCallback, useEffect, useState } from 'react';

function SearchResults() {
  const [results, setResults] = useState([]);
  
  // ✅ useCallback memoizes the function
  const performSearch = useCallback((query: string) => {
    return fetch(`/api/search?q=${query}`).then(r => r.json());
  }, []);  // No dependencies means function never changes
  
  useEffect(() => {
    performSearch('react').then(setResults);
  }, [performSearch]);  // Now safe to use as dependency
}
javascript
import { useCallback, useEffect, useState } from 'react';

function SearchResults() {
  const [results, setResults] = useState([]);
  
  // ✅ useCallback memoizes the function
  const performSearch = useCallback((query) => {
    return fetch(`/api/search?q=${query}`).then(r => r.json());
  }, []);  // No dependencies means function never changes
  
  useEffect(() => {
    performSearch('react').then(setResults);
  }, [performSearch]);  // Now safe to use as dependency
}

# Pitfall 2: Stale Closures

An effect captures values from the component when it runs. If you omit a dependency that changes, the effect uses the old value:

typescript
function Timer() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    const interval = setInterval(() => {
      console.log(`Count is ${count}`);  // ← Always logs 0!
    }, 1000);
    
    return () => clearInterval(interval);
  }, []);  // ❌ Missing 'count' dependency
}
javascript
function Timer() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    const interval = setInterval(() => {
      console.log(`Count is ${count}`);  // ← Always logs 0!
    }, 1000);
    
    return () => clearInterval(interval);
  }, []);  // ❌ Missing 'count' dependency
}

When count changes, the effect doesn't re-run, so the interval is never recreated. It still references the original count = 0 from the closure.

The fix is to add the missing dependency:

typescript
function Timer() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    const interval = setInterval(() => {
      console.log(`Count is ${count}`);  // ✅ Logs current value
    }, 1000);
    
    return () => clearInterval(interval);
  }, [count]);  // ✅ Include 'count'
}
javascript
function Timer() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    const interval = setInterval(() => {
      console.log(`Count is ${count}`);  // ✅ Logs current value
    }, 1000);
    
    return () => clearInterval(interval);
  }, [count]);  // ✅ Include 'count'
}

Now the effect re-runs when count changes, and the interval is recreated with the new value.

# Practical Application: Data Fetching Pattern

Here's a real-world pattern used by teams at ByteDance and Alibaba: fetching data when props change while handling race conditions:

typescript
import { useEffect, useState } from 'react';

interface User {
  id: number;
  name: string;
  email: string;
}

function UserProfile({ userId }: { userId: number }) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  
  useEffect(() => {
    // Flag to prevent state updates from stale requests
    let isMounted = true;
    
    async function loadUser() {
      try {
        setLoading(true);
        setError(null);
        
        const response = await fetch(`/api/users/${userId}`);
        if (!response.ok) throw new Error('Failed to fetch user');
        
        const data = await response.json();
        
        // Only update state if component is still mounted
        if (isMounted) {
          setUser(data);
        }
      } catch (err) {
        if (isMounted) {
          setError(err instanceof Error ? err.message : 'Unknown error');
        }
      } finally {
        if (isMounted) {
          setLoading(false);
        }
      }
    }
    
    loadUser();
    
    // Cleanup: mark as unmounted
    return () => {
      isMounted = false;
    };
  }, [userId]);  // Re-run when userId changes
  
  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  if (!user) return <div>No user found</div>;
  
  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

export default UserProfile;
javascript
import { useEffect, useState } from 'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    // Flag to prevent state updates from stale requests
    let isMounted = true;
    
    async function loadUser() {
      try {
        setLoading(true);
        setError(null);
        
        const response = await fetch(`/api/users/${userId}`);
        if (!response.ok) throw new Error('Failed to fetch user');
        
        const data = await response.json();
        
        // Only update state if component is still mounted
        if (isMounted) {
          setUser(data);
        }
      } catch (err) {
        if (isMounted) {
          setError(err instanceof Error ? err.message : 'Unknown error');
        }
      } finally {
        if (isMounted) {
          setLoading(false);
        }
      }
    }
    
    loadUser();
    
    // Cleanup: mark as unmounted
    return () => {
      isMounted = false;
    };
  }, [userId]);  // Re-run when userId changes
  
  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  if (!user) return <div>No user found</div>;
  
  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

export default UserProfile;

This pattern handles several critical scenarios:

  1. Dependency tracking: The effect re-runs when userId changes, fetching the new user's data.
  2. Race conditions: If userId changes while a fetch is in flight, the isMounted flag prevents stale data from being applied.
  3. Memory leaks: The cleanup function prevents state updates on unmounted components.
  4. No unnecessary dependencies: We only watch userId, not setUser, setLoading, or setError.

# FAQ

# Q: Should I add useCallback to everything to prevent dependency changes?

A: No. useCallback adds overhead and makes code harder to read. Use it only when:

  • A function is a dependency in another effect or memo
  • You're optimizing performance and have measured the improvement
  • A parent component passes the function to memoized children

In most cases, letting functions be recreated is fine. React's linter (ESLint) will tell you when you actually need it.

# Q: What if my effect depends on an object from props?

A: Extract the specific value you care about:

typescript
// ❌ Avoid
useEffect(() => {
  console.log(config.apiUrl);
}, [config]);  // Re-runs every render

// ✅ Better
const apiUrl = config.apiUrl;
useEffect(() => {
  console.log(apiUrl);
}, [apiUrl]);  // Only re-runs if apiUrl actually changes

# Q: Can I have multiple dependency arrays in one component?

A: Yes, but each useEffect call has its own array. This is actually a common pattern—separate effects for separate concerns:

typescript
useEffect(() => {
  // Effect 1: Fetch user data
}, [userId]);

useEffect(() => {
  // Effect 2: Subscribe to notifications
}, []);

useEffect(() => {
  // Effect 3: Save preferences
}, [preferences]);

# Q: Is the dependency array order important?

A: No, the order doesn't matter. React compares values, not positions. [a, b] and [b, a] are treated the same.

# Q: How do I know if I'm missing a dependency?

A: Install and run the ESLint plugin eslint-plugin-react-hooks. It automatically detects missing dependencies and warns you. This is the single best tool for preventing dependency-related bugs.


Related Articles:

Questions? Share your trickiest dependency array bug in the comments below—we learn best from real-world examples.

Sponsored Content

Google AdSense Placeholder

CONTENT SLOT

Sponsored

Google AdSense Placeholder

FOOTER SLOT