A pattern that comes up constantly when debugging React performance issues: a component re-renders far more often than it should, or worse, gets stuck in a visible infinite loop, and the actual cause traces back not to the rendering logic itself but to what was listed (or not listed) in a useEffect dependency array.
What the Dependency Array Actually Controls
React compares each value in the dependency array against its previous render using reference equality, not deep equality. The effect only re-runs when at least one of these comparisons comes back false. This sounds simple, but it means the actual behavior depends entirely on whether the values in that array are stable references across renders, which is where most of the real problems start.
The Classic Trap: Objects and Arrays Created Inline
function Component({ id }) {
const options = { id, sort: "asc" };
useEffect(() => {
fetchData(options);
}, [options]); // new object reference every render
}
Even though options looks identical in value on every render, it is a brand new object reference each time, so the dependency comparison always fails and the effect runs on every single render, regardless of whether id actually changed.
The fix: move the object creation inside the effect itself, depending only on the primitive values that actually matter.
function Component({ id }) {
useEffect(() => {
const options = { id, sort: "asc" };
fetchData(options);
}, [id]); // only re-runs when id actually changes
}
The Infinite Loop Variant: Setting State the Effect Indirectly Depends On
A more disruptive version of this same issue happens when an effect calls setState using a value derived from state that is also, directly or indirectly, in its own dependency array, creating a cycle where each render triggers a state update that triggers another render.
Worth checking directly: if your effect both reads and updates the same piece of state, switching to the functional form of setState (setCount(prev => prev + 1) rather than setCount(count + 1)) often lets you remove that state from the dependency array entirely, since the effect no longer needs to read the current value directly.
Missing Dependencies: The Problem in the Other Direction
The opposite mistake is just as common and considerably harder to notice, since it does not cause visible re-render churn — it causes silently stale data. If an effect uses a prop or piece of state that is not listed in the dependency array, the effect closure captures whatever that value was on the render when the effect was first set up, and continues using that stale value even after the actual prop or state has changed.
I have traced more than one “the data shown is one step behind what it should be” bug directly back to this exact pattern, where ESLint’s exhaustive-deps warning had been silenced rather than addressed.
When It Is Actually Correct to Omit a Dependency
The exhaustive-deps lint rule is right far more often than it is wrong, but there are legitimate cases — an effect that should genuinely only run once on mount, regardless of later prop changes — where omitting a dependency is intentional rather than a mistake. In these specific cases, using a ref to track the latest value (so the effect can read current data without needing it in the dependency array) is generally a cleaner solution than silencing the lint warning outright.
A Practical Decision Framework
Check whether the dependency is a primitive or a reference type. Objects, arrays, and functions created during render need to be wrapped in useMemo or useCallback, or recreated inside the effect itself, to avoid spurious re-runs.
Check whether the effect both reads and writes the same state. If so, the functional form of the state setter usually allows that state to be removed from the dependency array entirely.
Never silence the exhaustive-deps warning without understanding why it fired. In the cases where omitting a dependency is genuinely correct, a ref-based pattern is almost always a more reliable fix than disabling the lint rule.
Verify with React DevTools Profiler, not just by reading the dependency array, since the actual re-render frequency is what confirms whether a fix worked.
A Quick Reference Table
| Symptom | Likely Cause | Fix |
|---|---|---|
| Effect runs on every render | Object/array/function recreated inline as a dependency | useMemo/useCallback, or move creation inside the effect |
| Visible infinite loop | Effect sets state that is also a dependency | Use functional setState form |
| Stale data shown after props change | Missing dependency | Add the dependency, or use a ref if omission is intentional |
| ESLint exhaustive-deps warning silenced | Dependency genuinely needed, or genuinely intentional one-time effect | Add the dependency, or use ref pattern for intentional cases |
What Actually Changed Once I Started Tracing These Properly
Treating dependency array warnings as a debugging starting point rather than noise to suppress meant catching reference-instability bugs before they shipped, rather than after a user reported stale or duplicated data. The dependency array is not a formality — it is the actual mechanism determining when your effect runs, and treating it carelessly tends to surface as one of these specific failure modes sooner or later.
Are you seeing an effect run more often than expected, or a value that seems to lag behind? Describe the specific symptom and I can help trace which of these patterns is most likely responsible.
🔗 Recommended Reading
- useTransition and Concurrent Rendering: When It Actually Helps Performance
- When to Actually Use React.memo (And When Not To)
- useMemo vs useCallback: What Is Actually Different
- How to Use React DevTools Profiler to Find Real Bottlenecks
- Code Splitting in React: A Practical Guide to React.lazy and Suspense