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 |
|---|---|
| After every render (no array at all) |
| Once on mount; cleanup runs on unmount |
| On mount and whenever |
Form 1: No Dependency Array — Runs After Every Render
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
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
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 variables —
const [count, setCount] = useState(0)→countmust be in depsProps —
function Component({ userId })→userIdmust be in deps if used inside the effectContext values — values from
useContextAny variable defined inside the component — because it is re-created on every render
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.
// ❌ 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])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.
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.
// ❌ 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.
Quick Reference
Scenario | Correct deps |
|---|---|
Effect uses |
|
Effect uses |
|
Effect uses a | 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 |