The Dependency Array — Controlling When Effects Run

The dependency array is the most powerful and most frequently misused part of useEffect. It tells React which values the effect depends on — and React uses that information to decide when to re-run the effect. Getting the dependency array right is critical: too few dependencies produces stale data and hard-to-debug bugs; too many produces unnecessary re-runs; an empty array when dependencies are needed causes subtle logic errors that only surface in specific user flows. In this lesson you will master every aspect of the dependency array and build the habit of listing dependencies correctly the first time.

The Three Forms Revisited

// Form 1: No dependency array — effect runs after EVERY render
// Use case: extremely rare — debugging, measuring renders
useEffect(() => {
  console.log('PostCard rendered', Date.now());
});

// Form 2: Empty array — effect runs ONCE after mount
// Use case: fetch initial data, set up global listeners, one-time operations
useEffect(() => {
  fetchInitialPosts();
}, []);

// Form 3: Array with dependencies — effect runs after mount AND when any dep changes
// Use case: refetch when a filter/ID changes, sync state to storage
useEffect(() => {
  fetchPosts({ tag: selectedTag, page: currentPage });
}, [selectedTag, currentPage]); // re-runs whenever either changes
Note: React compares dependency values using Object.is — similar to strict equality (===). Primitives (strings, numbers, booleans) compare by value: 'mern' === 'mern' is true so the effect does not re-run. Objects and arrays compare by reference: even if two arrays contain the same items, [] !== [], so a new array object on every render would cause the effect to re-run on every render. Avoid passing object or array literals directly in the dependency array.
Tip: Install the eslint-plugin-react-hooks package and enable the exhaustive-deps rule — it automatically warns you when you are missing a dependency. Vite projects created with npm create vite@latest include this rule by default. Never silence the rule with a comment unless you have a specific, deliberate reason — it is almost always right.
Warning: The most common dependency array mistake is listing a function defined inside the component. Functions recreate on every render, so their reference changes every render. Including such a function in the dependency array causes the effect to re-run on every render. The fix is to either move the function inside the effect, wrap it in useCallback, or restructure the code to avoid the dependency.

What Happens Step by Step

useEffect(() => { fetchPosts(page); }, [page]);

Initial render (page = 1):
  → React renders the component
  → React commits the DOM changes
  → useEffect runs: fetchPosts(1)

User changes page to 2:
  → React re-renders (page = 2)
  → React commits DOM changes
  → React runs the cleanup from the previous effect (if any)
  → useEffect runs again: fetchPosts(2)

User changes page to 2 AGAIN (same value):
  → React re-renders (page = 2)
  → React compares: Object.is(2, 2) === true → NO change
  → useEffect does NOT run again

Missing Dependencies — Stale Closure Bug

// ❌ Wrong: postId is used inside the effect but not listed as a dependency
function PostPage({ postId }) {
  const [post, setPost] = useState(null);

  useEffect(() => {
    fetchPost(postId).then(setPost); // postId captured from the first render
    // If postId changes (user navigates to a different post),
    // this effect does NOT re-run — it still shows the old post's data!
  }, []); // ← missing postId
}

// ✅ Correct: list postId as a dependency
useEffect(() => {
  fetchPost(postId).then(setPost);
}, [postId]); // re-runs whenever postId changes — always fresh data

Object and Array Dependencies — The Reference Problem

// ❌ Wrong: object literal creates a new reference on every render
function PostList({ filters }) {
  const options = { tag: filters.tag, page: filters.page }; // new object every render!

  useEffect(() => {
    fetchPosts(options);
  }, [options]); // options !== options (new reference) → effect runs every render
}

// ✅ Fix 1: Use primitive values from the object as dependencies
useEffect(() => {
  fetchPosts({ tag: filters.tag, page: filters.page });
}, [filters.tag, filters.page]); // primitives compare by value ✓

// ✅ Fix 2: Move the object creation inside the effect
useEffect(() => {
  const options = { tag: filters.tag, page: filters.page }; // created inside — no stale closure
  fetchPosts(options);
}, [filters.tag, filters.page]); // ✓

// ✅ Fix 3: Memoize the object with useMemo so it only changes when values change
const options = useMemo(
  () => ({ tag: filters.tag, page: filters.page }),
  [filters.tag, filters.page]
);

Function Dependencies — the useCallback Solution

// ❌ Wrong: function re-created every render → effect re-runs every render
function PostList({ onLoadComplete }) {
  useEffect(() => {
    fetchPosts().then(posts => {
      setPosts(posts);
      onLoadComplete(posts.length); // uses onLoadComplete
    });
  }, [onLoadComplete]); // onLoadComplete is a new function every render!
}

// ✅ Fix: wrap the function in useCallback in the parent
// Parent component:
const handleLoadComplete = useCallback((count) => {
  setPostCount(count);
}, []); // stable reference — only created once

// Now onLoadComplete has a stable reference — effect only runs once
<PostList onLoadComplete={handleLoadComplete} />

The Exhaustive Deps Rule in Practice

function BlogPage() {
  const [posts,   setPosts]   = useState([]);
  const [tag,     setTag]     = useState('');
  const [page,    setPage]    = useState(1);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    // Everything used inside this effect must be in the dependency array
    const fetchData = async () => {
      setLoading(true);
      try {
        const res  = await axios.get('/api/posts', { params: { tag, page, limit: 10 } });
        setPosts(res.data.data);
      } finally {
        setLoading(false);
      }
    };
    fetchData();
  }, [tag, page]);
  // ✓ tag and page are in deps — re-fetches when filter or page changes
  // ✓ setPosts and setLoading are stable (React guarantees setter stability) — OK to omit
  // ✓ axios is a module import (stable reference) — OK to omit
}

Common Mistakes

Mistake 1 — Empty array when a dependency is needed

❌ Wrong — effect does not see prop changes:

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  useEffect(() => {
    fetchUser(userId).then(setUser); // userId from first render only
  }, []); // missing userId → stale if userId changes
}

✅ Correct:

useEffect(() => {
  fetchUser(userId).then(setUser);
}, [userId]); // ✓ re-fetches when userId changes

Mistake 2 — Suppressing the exhaustive-deps warning

❌ Wrong — silencing the lint rule hides the bug:

useEffect(() => {
  fetchPosts(tag);
  // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // tag missing — stale bug hidden by suppression

✅ Correct — fix the deps, do not suppress the warning.

Mistake 3 — Object literal directly in dependency array

❌ Wrong — new object on every render causes infinite re-runs:

useEffect(() => { fetchPosts(filters); }, [{ tag, page }]); // always new reference!

✅ Correct — use the primitive values:

useEffect(() => { fetchPosts({ tag, page }); }, [tag, page]); // ✓

Quick Reference

Dependency Include in Array? Reason
State variable used in effect Yes Effect may be stale if state changes
Prop used in effect Yes Effect may be stale if prop changes
State setter (setPosts) No React guarantees stable reference
Module import (axios) No Module references never change
Object/array literal No — use primitives instead New reference every render causes infinite loops
Function defined in component Only if wrapped in useCallback Otherwise new reference every render

🧠 Test Yourself

You have useEffect(() => { fetchUser(userId).then(setUser); }, []). The effect runs once on mount. The user navigates from /users/1 to /users/2 and the userId prop changes to 2. What is shown?