ReactuseEffect Dependency Array

useEffect Dependency Array

The second argument to useEffect — the dependency array — is one of the most important and most misunderstood parts of the hook. It tells React when to re-run your effect. Getting it wrong leads to either stale data (running too rarely) or infinite loops (running too often).

Three Forms of the Dependency Array

Form

When the effect runs

useEffect(fn)

After every render (no array at all)

useEffect(fn, [])

Once on mount; cleanup runs on unmount

useEffect(fn, [a, b])

On mount and whenever a or b changes

Form 1: No Dependency Array — Runs After Every Render

JSX
import { useEffect } from 'react'

function LogRender({ data }) {
  // No array — runs after EVERY render, for any reason
  useEffect(() => {
    console.log('Component rendered with', data)
  })

  return <div>{data}</div>
}

This is rarely what you want in production. If any state or prop changes — even ones unrelated to your effect — the effect runs again. It is useful for debugging but avoid it for subscriptions or data fetching.

Form 2: Empty Array — Runs Once on Mount

JSX
import { useEffect } from 'react'

function Analytics() {
  // [] — runs exactly once when the component first appears
  useEffect(() => {
    trackPageView('/dashboard')

    // cleanup runs when the component leaves the screen
    return () => {
      trackPageExit('/dashboard')
    }
  }, [])

  return <Dashboard />
}

An empty array means "this effect has no dependencies" — there is nothing that could make React want to re-run it. The effect fires once after the first render, and its cleanup fires once when the component unmounts.

Form 3: Specific Dependencies — Runs When They Change

JSX
import { useState, useEffect } from 'react'

function UserProfile({ userId }) {
  const [user, setUser] = useState(null)

  // Runs on mount AND whenever userId changes
  useEffect(() => {
    const controller = new AbortController()

    fetch(`/api/users/${userId}`, { signal: controller.signal })
      .then(r => r.json())
      .then(setUser)
      .catch(() => {}) // AbortError on cleanup

    return () => controller.abort()
  }, [userId]) // <-- userId is the only dependency

  return <div>{user?.name}</div>
}

React compares each dependency using Object.is (similar to ===). When any value changes between renders, the cleanup from the previous run fires, then the effect runs again with the new values.

What Must Go in the Dependency Array

The rule is simple: every reactive value used inside the effect must be listed as a dependency. A reactive value is anything that can change between renders:

  • State variablesconst [count, setCount] = useState(0)count must be in deps

  • Propsfunction Component({ userId })userId must be in deps if used inside the effect

  • Context values — values from useContext

  • Any variable defined inside the component — because it is re-created on every render

JSX
function SearchResults({ query, pageSize }) {
  const [results, setResults] = useState([])

  useEffect(() => {
    // 'query' and 'pageSize' are both used → both must be deps
    fetchResults(query, pageSize).then(setResults)
  }, [query, pageSize]) // ✅ complete

  return <ResultsList items={results} />
}
The exhaustive-deps ESLint Rule

The eslint-plugin-react-hooks package ships a rule called react-hooks/exhaustive-deps. It statically analyzes your effects and warns when you are missing a dependency. Treat these warnings as errors — they always indicate a real bug or design issue.

JSX
// ❌ ESLint warning: 'userId' is missing from the deps array
useEffect(() => {
  fetch(`/api/users/${userId}`).then(setUser)
}, []) // missing userId

// ✅ Correct
useEffect(() => {
  fetch(`/api/users/${userId}`).then(setUser)
}, [userId])
Warning
Do not suppress exhaustive-deps warnings by adding `// eslint-disable-next-line`. Instead, fix the root cause. If the warning seems wrong, it almost always means your effect is doing too much and should be split, or a value should be moved outside the component.
Stale Closures: The Bug from Missing Dependencies

When you omit a dependency, your effect captures the initial value of that variable and never updates. This is called a stale closure — the closure closes over an old version of the value.

JSX
function StaleCounter() {
  const [count, setCount] = useState(0)

  useEffect(() => {
    const id = setInterval(() => {
      // ❌ BUG: 'count' is always 0 here because the effect
      // closed over the initial value and never re-ran.
      setCount(count + 1) // always sets to 0 + 1 = 1
    }, 1000)
    return () => clearInterval(id)
  }, []) // missing 'count' dependency

  return <p>{count}</p>
}

// ✅ Fix 1: Add 'count' to deps (but this restarts the interval every second)
useEffect(() => {
  const id = setInterval(() => {
    setCount(count + 1)
  }, 1000)
  return () => clearInterval(id)
}, [count])

// ✅ Fix 2 (better): Use the functional updater form — no dependency needed
useEffect(() => {
  const id = setInterval(() => {
    setCount(prev => prev + 1) // always has the latest value
  }, 1000)
  return () => clearInterval(id)
}, [])
Object and Function Identity: The Infinite Loop Trap

React compares dependencies with Object.is. For primitives (string, number, boolean), this works as expected. For objects and functions, it compares references, not contents. A new object literal created in the render body will be a different reference on every render, even if its contents are the same — causing your effect to re-run on every render, potentially triggering an infinite loop.

JSX
// ❌ Infinite loop — 'options' is a new object on every render
function DataFetcher({ userId }) {
  const options = { method: 'GET', headers: { 'X-User': userId } } // new ref every render

  useEffect(() => {
    fetch('/api/data', options).then(...)
  }, [options]) // triggers every render → effect re-runs → re-render → repeat
}

// ✅ Fix 1: Move the object inside the effect (no dep needed)
useEffect(() => {
  const options = { method: 'GET', headers: { 'X-User': userId } }
  fetch('/api/data', options).then(...)
}, [userId]) // only userId is a real dependency

// ✅ Fix 2: Use useMemo for a stable reference
const options = useMemo(
  () => ({ method: 'GET', headers: { 'X-User': userId } }),
  [userId]
)
useEffect(() => {
  fetch('/api/data', options).then(...)
}, [options])

The same issue affects functions defined in the component body — they get a new reference every render. The fix is either to define the function inside the effect, or use useCallback.

Note
A good rule of thumb: if an object or function is only used inside the effect, move it *inside* the effect. That way it is not a dependency at all, and you sidestep the identity problem entirely.
Quick Reference

Scenario

Correct deps

Effect uses count state

[count]

Effect uses userId prop

[userId]

Effect uses a setX setter

Omit — setters are stable references

Effect sets up a one-time listener

[]

Effect uses an object built in render

Move the object inside the effect

Effect uses a function built in render

Move inside effect or useCallback

Tip
State setter functions (the `setX` from `useState`) and `dispatch` from `useReducer` have a **stable identity** — they never change between renders. You do not need to include them in your dependency array, and the exhaustive-deps rule is smart enough to know this.