useEffect Dependency Array: Stale Closures and Loops
The dependency array in useEffect is a powerful tool, but it comes with strict rules. Breaking them causes two notorious bugs: stale closures (where your effect sees outdated state) and infinite loops (where effects re-run on every render). The ESLint exhaustive-deps rule catches missing dependencies, while the useCallback hook stabilizes function references. Master these patterns to write predictable, bug-free effects.
Key Takeaways
- Stale closures occur when you omit a value from the dependency array; the effect uses the initial value forever
- Infinite loops happen when the dependency array includes objects or functions that are recreated on every render
- The
exhaustive-depsESLint rule (fromeslint-plugin-react-hooks) automatically warns you of missing dependencies useCallbackmemoizes functions so their reference stays stable across renders unless their own dependencies change- The solution is: list all dependencies + use
useCallbackfor function props + useuseMemofor object/array deps
What Is a Stale Closure and How Does It Happen?
Understanding Closures in useEffect
A closure is a function that "remembers" the variables from the scope where it was created. When you define a useEffect callback, that callback is a closure. If you omit a dependency, the callback remembers the value from the initial render and never gets the updated value.
Here is the classic stale closure example:
import React, { useState, useEffect } from 'react';
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const intervalId = setInterval(() => {
// This callback closes over 'count'.
// At the time the effect runs (once, on mount), count is 0.
console.log(`Count is: ${count}`);
}, 2000);
return () => clearInterval(intervalId);
}, []); // WRONG: 'count' is not in the dependency array!
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
</div>
);
}
What happens: The effect runs once on mount and creates an interval. That interval logs count, but the count variable in the closure is frozen at 0. Every time you click the button, the state updates and the component re-renders, but the effect does not run again (because the dependency array is empty). The console logs 0 forever, never 1, 2, 3, etc.
The Fix: Add the Dependency
The solution is simple: include count in the dependency array:
useEffect(() => {
const intervalId = setInterval(() => {
console.log(`Count is: ${count}`);
}, 2000);
return () => clearInterval(intervalId);
}, [count]); // Now the effect re-runs whenever 'count' changes
Now, every time count changes, React runs the effect again, creating a fresh closure over the new value of count. The interval logs the current count correctly.
Why This Matters
Stale closures are insidious because they are silent. Your code runs without errors; it just uses outdated data. The effect might fetch stale data from an API, display an outdated value, or trigger actions with wrong parameters. Always remember: list every variable your effect uses as a dependency.
What Is an Infinite Loop in useEffect and Why Does It Happen?
The Reference Equality Problem
JavaScript compares objects and functions by reference, not value. Two objects with identical properties are not equal:
const obj1 = { name: 'Alice' };
const obj2 = { name: 'Alice' };
console.log(obj1 === obj2); // false, different objects in memory
In useEffect, the dependency array is compared by reference. If a dependency is a new object or function on every render, React sees it as "changed" and re-runs the effect:
import React, { useState, useEffect } from 'react';
function InfiniteLoop() {
const [count, setCount] = useState(0);
// This object is created fresh on every render.
const options = { threshold: 0.5, timeout: 3000 };
useEffect(() => {
console.log('Effect running...');
// If this effect modifies state, we get an infinite loop:
// setCount(c => c + 1); // Would cause infinite render -> effect -> state change -> render...
}, [options]); // 'options' is a new object every render, so effect runs every render
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
</div>
);
}
What happens: The options object is created new on every render. React compares dependencies and sees options has changed (different reference). The effect runs. If that effect caused a re-render (e.g., by calling setCount), we get: render → effect → state change → render → effect → ... an infinite loop.
The Classic Infinite Loop Trap
Here is a real-world scenario that crashes React:
function DataFetcher({ userId }) {
const [data, setData] = useState(null);
// This function is new every render.
const fetchUser = () => {
fetch(`/api/users/${userId}`).then(res => res.json()).then(setData);
};
useEffect(() => {
fetchUser(); // Causes a re-render via setData
}, [fetchUser]); // fetchUser is new every render → infinite loop!
}
Every render creates a new fetchUser function. The effect sees it as a new dependency and runs. The effect calls fetchUser, which calls setData, which triggers a re-render, which creates a new fetchUser, which... infinite loop.
How Do You Solve These Problems?
Solution 1: The exhaustive-deps ESLint Rule
The React team provides an ESLint rule that automatically catches missing dependencies. Install it:
npm install --save-dev eslint-plugin-react-hooks
Add it to your .eslintrc config:
{
"plugins": ["react-hooks"],
"rules": {
"react-hooks/exhaustive-deps": "warn"
}
}
Now, if you omit a dependency, ESLint will warn you:
useEffect(() => {
console.log(count); // ESLint warning: 'count' is missing from dependency array
}, []); // Fix: add [count]
This is the first line of defense against both stale closures and infinite loops. Use it in every React project.
Solution 2: useCallback for Function Dependencies
For the infinite loop caused by passing functions as dependencies, use useCallback to memoize the function. useCallback returns the same function reference across renders unless its dependencies change:
import React, { useState, useEffect, useCallback } from 'react';
function DataFetcher({ userId }) {
const [data, setData] = useState(null);
// useCallback memoizes the function.
// It only creates a NEW function when userId changes.
const fetchUser = useCallback(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(setData);
}, [userId]); // Dependencies of fetchUser
useEffect(() => {
fetchUser(); // Now fetchUser has a stable reference
}, [fetchUser]); // Only re-runs when userId changes (when fetchUser changes)
return data ? <div>{data.name}</div> : <p>Loading...</p>;
}
How it works: useCallback ensures fetchUser has the same reference across renders (unless userId changes). The useEffect depends on fetchUser, so it only runs when userId changes, which is the correct behavior.
Solution 3: useMemo for Object and Array Dependencies
For objects and arrays, use useMemo to memoize them:
import React, { useState, useEffect, useMemo } from 'react';
function MyComponent({ userId }) {
const [data, setData] = useState(null);
// Memoize the options object.
// It only creates a NEW object when userId changes.
const options = useMemo(() => ({
threshold: 0.5,
userId: userId, // Depends on userId
}), [userId]);
useEffect(() => {
console.log('Options changed:', options);
// Fetch data with options...
}, [options]); // Now stable unless userId changes
}
Practical Guidelines for Dependency Arrays
The Complete Workflow
- List every variable your effect uses (primitive, object, function, from props or state)
- Run the
exhaustive-depsESLint rule — it will warn you of omissions - For function dependencies, wrap in
useCallbackto keep reference stable - For object/array dependencies, wrap in
useMemoto keep reference stable - Think about your intent — should the effect re-run when this dependency changes? If no, that's a sign the variable shouldn't be a dependency
Real-World Example
function SearchUsers({ searchTerm }) {
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
// Memoize the API endpoint to avoid recreating it every render
const searchUrl = useMemo(() => ({
base: '/api/search',
term: searchTerm,
}), [searchTerm]);
// Memoize the fetch function
const performSearch = useCallback(() => {
setLoading(true);
fetch(`${searchUrl.base}?q=${searchUrl.term}`)
.then(res => res.json())
.then(data => {
setResults(data);
setLoading(false);
});
}, [searchUrl]);
// Effect depends only on the memoized performSearch
useEffect(() => {
if (searchTerm.length > 2) {
performSearch();
}
}, [performSearch, searchTerm]);
return (
<div>
{loading && <p>Loading...</p>}
<ul>
{results.map(user => <li key={user.id}>{user.name}</li>)}
</ul>
</div>
);
}
Frequently Asked Questions
Is it better to list dependencies or use an empty array?
List dependencies. An empty array means the effect runs only once; it prevents stale closures but requires the effect to be completely independent of props/state. In most real-world scenarios, you need the effect to re-run when certain values change. Always use exhaustive-deps to guide you.
What if I need an effect to run only once but it uses state/props?
This is a sign your component design might benefit from refactoring. However, if you absolutely need it, consider whether you need the current value or just the initial value. If the initial value suffices, the empty array is correct. Otherwise, you probably do need the effect to re-run.
Does useCallback and useMemo have a performance cost?
Yes, they have a small cost: the comparison of their own dependencies. They should be used only when you have a performance problem (usually identified via profiling). For most applications, memoizing function/object dependencies in useEffect is worthwhile because it prevents bugs and unnecessary re-runs; the cost is negligible.
Can I suppress the exhaustive-deps warning if I know better?
You can use the ESLint rule's disable comment, but don't. If you think you know better than the rule, you are probably missing something. The rule is battle-tested and catches real bugs. Suppress it only in rare, documented edge cases.
Further Reading
- React Official Docs: useEffect Dependencies
- React Official Docs: useCallback
- ESLint plugin: react-hooks
Last updated: June 2, 2026 by Dr. Alex Turner