React 31 ⚛️ Effect Dependency Array Rules
The dependency array is the control panel for useEffect. It determines when the effect runs, when it is cleaned up, and when it is skipped. The rules that govern it are simple to state and easy to violate: include every value from the component scope that the effect reads. The consequences of violating them are subtle: an effect that runs too often, an effect that runs with stale data, or an infinite loop that freezes the browser. This chapter covers the dependency array in full: what belongs in it, what does not, the infinite loop patterns, the stale closure problem, and the escape hatches when the rules seem to conflict with the desired behavior.
Key point: The dependency array lists the reactive values the effect depends on. React compares the array to the previous array with Object.is on each element. If any element differs, the effect re-runs. An empty array means the effect runs once. No array means the effect runs after every render. An effect that reads a value from the component scope without listing it in the array is a bug, even if the bug does not surface immediately.
Why the dependency array matters
The synchronization problem. An effect synchronizes React with something outside React: a subscription, a timer, a network request, a DOM API. The synchronization must happen when the external system’s inputs change. The dependency array is the list of inputs. If the list is incomplete, the synchronization lags behind. If the list is too broad, the synchronization runs when nothing relevant changed.
The stale closure problem. An effect captures the values from the render in which it ran. If the effect reads a variable that is not in its dependency array, it keeps reading the value from the original render, even after the variable has changed. This is the stale closure. The effect runs with old data, and the external system is synchronized to a state that no longer exists.
The infinite loop problem. An effect that updates a value in its dependency array causes the effect to re-run, which updates the value again, which causes the effect to re-run. The loop is infinite. The fix is to remove the value from the dependencies or to use a functional update that does not read the current value.
The performance problem. An effect that runs after every render is expensive. The dependency array narrows it to the renders where the relevant values changed. An effect that runs on every keystroke when it only needs to run on the submitted value is a waste.
The lint problem. The eslint-plugin-react-hooks rule exhaustive-deps warns when an effect uses a value that is not in its dependency array. The warning is not a style preference; it is a correctness check. Suppressing the warning is suppressing the bug.
The exam problem. React interview questions and code reviews commonly test the dependency array. The stale closure and the infinite loop are the two most common bugs in React code, and both are dependency array bugs.
a. The rules of the dependency array
The rule is: include every reactive value from the component scope that the effect reads.
Reactive values are:
- Props
- State
- Variables and functions declared in the component body
- Values derived from the above
Non-reactive values are:
- Constants defined outside the component
- Refs (
useRefreturns a stable object) - Setter functions from
useState(React guarantees they are stable) - The
dispatchfunction fromuseReducer
function Component({ userId }) {
const [count, setCount] = useState(0);
const theme = 'dark'; // constant, re-created each render but same value
useEffect(() => {
console.log(userId, count, theme);
}, [userId, count]); // theme is a constant, not needed
}
The effect reads userId, count, and theme. The userId is a prop, and count is state. Both are reactive and belong in the array. The theme is a constant with the same value on every render. It does not need to be in the array, though including it is harmless because its value never changes.
The setCount function is guaranteed stable by React. It does not need to be in the array.
useEffect(() => {
setCount(1);
}, []); // setCount is stable
A ref is a stable object. The ref object does not change, but its current property does. Including the ref in the array does nothing, because the ref object is the same. Reading ref.current in the effect reads the current value at the time the effect runs, which may not be the value the effect should react to.
const inputRef = useRef(null);
useEffect(() => {
console.log(inputRef.current);
}, []); // ref object is stable
The inputRef.current may be null on the first render (before the DOM is attached) and the element afterward. The effect does not re-run when the ref changes, because the ref object is stable.
The array is compared element by element with Object.is. A new object or array on every render is a new value and causes the effect to re-run.
// BAD: new object every render
const options = { limit: 10 };
useEffect(() => {
fetchData(options);
}, [options]); // runs on every render
// GOOD: primitive values
useEffect(() => {
fetchData({ limit: 10 });
}, []); // runs once
The options object is a new object on every render. The Object.is comparison returns false. The effect runs on every render.
The fix is to depend on the primitive values that the object is built from, or to memoize the object with useMemo.
const options = useMemo(() => ({ limit: 10 }), []);
useEffect(() => {
fetchData(options);
}, [options]); // runs once
The useMemo returns the same object until the dependencies change. With an empty dependency array, the object is stable, and the effect runs once.
b. The stale closure and the infinite loop
The stale closure is the bug that occurs when the effect reads a value that is not in its dependency array.
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log(count); // always the initial count
}, 1000);
return () => clearInterval(id);
}, []); // count not in dependencies
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
The console.log(count) inside the interval logs the initial value on every tick. The effect captured count from the first render, and the interval closure holds that value. The count updates in the state, but the effect does not re-run, so the closure never sees the new value.
The fix is to add count to the dependencies.
useEffect(() => {
const id = setInterval(() => {
console.log(count);
}, 1000);
return () => clearInterval(id);
}, [count]); // count in dependencies
Now the effect re-runs when count changes. The old interval is cleared, and a new one is created with the new value. The console.log sees the current value.
But re-creating the interval on every count change is wasteful. The interval is a resource that should be created once and destroyed once. The fix is to use a ref to hold the current value.
const countRef = useRef(count);
useEffect(() => {
countRef.current = count;
}, [count]);
useEffect(() => {
const id = setInterval(() => {
console.log(countRef.current);
}, 1000);
return () => clearInterval(id);
}, []);
The first effect updates the ref whenever count changes. The second effect creates the interval once. The interval reads countRef.current, which is always the current value. The ref is the bridge between the changing value and the stable effect.
The infinite loop is the bug that occurs when the effect updates a value in its dependency array.
// BAD: infinite loop
useEffect(() => {
setCount(count + 1);
}, [count]);
The effect runs, updates count, which triggers the effect again, which updates count again. The loop is infinite.
The fix depends on the intent.
If the effect should run once, use an empty dependency array and a functional update.
useEffect(() => {
setCount((c) => c + 1);
}, []);
The functional update reads the current value without the effect depending on it.
If the effect should update count when something else changes, depend on the something else.
useEffect(() => {
setCount(initialCount);
}, [initialCount]);
The effect runs when initialCount changes, and updates count to the new initial value. The count is not in the dependencies because the effect does not read it.
If the effect should derive count from count, the state is being used as a derived value. A useState with a derived value is a code smell. The value should be computed with useMemo or directly in the render.
// BAD: derived state
const [count, setCount] = useState(0);
useEffect(() => {
setCount(items.length);
}, [items]);
// GOOD: derived value
const count = items.length;
The count is derived from items. It does not need state or an effect. The render computes it.
c. The escape hatches and the lint rule
The exhaustive-deps lint rule enforces the dependency array. The rule is a correctness check, and the warning should be addressed. There are a few cases where the warning is a false positive, and the escape hatches are the ways to address them.
The ref escape hatch. A ref is stable, but reading ref.current reads a value that is not tracked. The effect that reads ref.current does not need the ref in its dependencies. The lint rule does not warn about refs.
const countRef = useRef(count);
useEffect(() => {
console.log(countRef.current);
}, []); // no warning, ref is stable
The stable function escape hatch. A function declared in the component body is a new function on every render. If the effect calls it, the lint rule warns. The fix is to wrap the function in useCallback, which returns a stable function until its dependencies change.
const handleClick = useCallback(() => {
console.log(count);
}, [count]);
useEffect(() => {
handleClick();
}, [handleClick]); // stable, only changes when count changes
The useCallback memoizes the function. The effect depends on the memoized function.
The mount-only escape hatch. An effect that should run only on mount has an empty dependency array. The lint rule warns if the effect reads any value from the component scope.
useEffect(() => {
console.log(userId); // warns: userId not in dependencies
}, []); // mount only
The warning is correct: the effect runs only on mount, and it captures userId from the first render. If userId changes, the effect does not re-run, and the log shows the old value. This is the stale closure.
If the intent is “run on mount with the initial value,” the warning is a false positive. The effect should not re-run when userId changes, but the lint rule cannot know that.
The escape hatch is a comment.
useEffect(() => {
console.log(userId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
The comment suppresses the warning for the next line. It should be used sparingly and with a comment explaining why.
The functional update escape hatch. An effect that updates state based on the previous state does not need the state in its dependencies if the update uses the functional form.
useEffect(() => {
setCount((c) => c + 1); // does not read count
}, []); // no warning
The functional update reads the current value from React’s internal state, not from the closure. The effect does not depend on count.
The useReducer escape hatch. A useReducer dispatch function is stable. An effect that dispatches an action does not need the dispatch in its dependencies. The reducer reads the current state, not the closure.
const [state, dispatch] = useReducer(reducer, initialState);
useEffect(() => {
dispatch({ type: 'init' });
}, []); // dispatch is stable
The useEffectEvent escape hatch (experimental). React has an experimental useEffectEvent hook that wraps a function so it can read the latest values without being in the dependencies. The hook is not stable in current React versions, but it is the direction the React team is moving.
const onVisit = useEffectEvent((url) => {
logVisit(url, count); // reads count without it being a dependency
});
useEffect(() => {
onVisit(url);
}, [url]); // count is not a dependency
The useEffectEvent is the official solution to the “I need the latest value but I do not want to re-run the effect” problem. Until it stabilizes, the ref pattern is the workaround.
Complete Example Session
// ============================================
// PART 1: EMPTY ARRAY — RUN ONCE
// ============================================
useEffect(() => {
console.log('Mounted');
}, []);
// ============================================
// PART 2: NO ARRAY — RUN EVERY RENDER
// ============================================
useEffect(() => {
console.log('After every render');
});
// ============================================
// PART 3: WITH DEPENDENCIES
// ============================================
useEffect(() => {
console.log(userId);
}, [userId]);
// ============================================
// PART 4: MULTIPLE DEPENDENCIES
// ============================================
useEffect(() => {
fetchData(userId, token);
}, [userId, token]);
// ============================================
// PART 5: STALE CLOSURE
// ============================================
useEffect(() => {
const id = setInterval(() => {
console.log(count); // stale
}, 1000);
return () => clearInterval(id);
}, []);
// ============================================
// PART 6: FIX WITH DEPENDENCY
// ============================================
useEffect(() => {
const id = setInterval(() => {
console.log(count);
}, 1000);
return () => clearInterval(id);
}, [count]);
// ============================================
// PART 7: FIX WITH REF
// ============================================
const countRef = useRef(count);
useEffect(() => {
countRef.current = count;
}, [count]);
useEffect(() => {
const id = setInterval(() => {
console.log(countRef.current);
}, 1000);
return () => clearInterval(id);
}, []);
// ============================================
// PART 8: INFINITE LOOP
// ============================================
useEffect(() => {
setCount(count + 1); // loop
}, [count]);
// ============================================
// PART 9: FIX WITH FUNCTIONAL UPDATE
// ============================================
useEffect(() => {
setCount((c) => c + 1); // no loop
}, []);
// ============================================
// PART 10: LINT ESCAPE HATCH
// ============================================
useEffect(() => {
console.log(userId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // mount only, initial value
The ten parts covered the empty array, no array, dependencies, multiple dependencies, the stale closure, the dependency fix, the ref fix, the infinite loop, the functional update fix, and the lint escape hatch.
Quick Reference
Dependency Array Behavior
| Array | Effect Runs |
|---|---|
| Omitted | After every render |
[] | Once on mount |
[a] | On mount and when a changes |
[a, b] | On mount and when a or b changes |
What Belongs in the Array
| Value | In Array? |
|---|---|
| Prop | Yes |
| State | Yes |
| Function from component body | Yes |
| Variable from component body | Yes |
useRef object | No |
setState function | No |
dispatch from useReducer | No |
| Constant outside component | No |
useCallback memoized function | Yes |
Comparison
| Comparison | Result |
|---|---|
Object.is(1, 1) | true |
Object.is({}, {}) | false |
Object.is(a, a) | true |
| New object each render | Effect re-runs |
Common Bugs
| Bug | Cause | Fix |
|---|---|---|
| Stale closure | Missing dependency | Add to array or use ref |
| Infinite loop | Effect updates its dependency | Functional update or remove |
| Effect runs too often | New object in dependencies | useMemo |
| Effect runs once with old data | Empty array with reads | Add dependencies |
| Re-subscribe on every render | Function in dependencies | useCallback |
Escape Hatches
| Hatch | Use When |
|---|---|
| Ref | Need latest value without re-running |
useCallback | Function dependency is unstable |
| Functional update | State update based on previous |
eslint-disable-next-line | Intent is mount-only |
useEffectEvent (experimental) | Need latest value in an event |
Best Practices
✅ Do This:
// Include every reactive value the effect reads
useEffect(() => {
fetchData(userId);
}, [userId]); // ✅
// Use an empty array for mount-only effects that read nothing
useEffect(() => {
console.log('mounted');
}, []); // ✅
// Use a ref for the latest value without re-running
const ref = useRef(value);
useEffect(() => { ref.current = value; }, [value]); // ✅
// Use functional updates to avoid dependency on state
useEffect(() => {
setCount((c) => c + 1);
}, []); // ✅
// Use useCallback for function dependencies
const handler = useCallback(() => {}, [dep]);
useEffect(() => { handler(); }, [handler]); // ✅
// Wrap objects in useMemo
const options = useMemo(() => ({ limit: 10 }), []); // ✅
❌ Don’t Do This:
// Don't omit a dependency the effect reads
useEffect(() => {
fetchData(userId);
}, []); // ❌ stale
// Don't update state in the dependencies
useEffect(() => {
setCount(count + 1);
}, [count]); // ❌ loop
// Don't create a new object in the dependencies
useEffect(() => {
fetchData({ id });
}, [{ id }]); // ❌ new object
// Don't suppress the lint rule without understanding
// eslint-disable-next-line react-hooks/exhaustive-deps // ⚠️
// Don't use a ref for a value the effect should react to
// The ref does not trigger the effect // ⚠️
// Don't use an empty array when the effect reads values
useEffect(() => {
console.log(count);
}, []); // ❌ stale
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Stale closure | Missing dependency | Add to array |
| Infinite loop | Effect updates dependency | Functional update |
| Runs on every render | New object in deps | useMemo |
| Re-subscribes constantly | Function in deps | useCallback |
| Lint warning ignored | Misunderstanding | Address or document |
| Effect runs twice | StrictMode | Expected in development |
| Ref does not trigger | Refs are not reactive | Use state or add a dependency |
Real-World Examples
1. Fetch on Prop Change
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]);
2. Fetch on Mount
useEffect(() => {
fetchData().then(setData);
}, []);
3. Timer with Current Value
const ref = useRef(count);
useEffect(() => { ref.current = count; }, [count]);
useEffect(() => {
const id = setInterval(() => console.log(ref.current), 1000);
return () => clearInterval(id);
}, []);
4. Event Subscription
useEffect(() => {
const handler = () => setWidth(window.innerWidth);
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
}, []);
5. Document Title
useEffect(() => {
document.title = `${count} clicks`;
}, [count]);
6. Debounced Search
useEffect(() => {
const id = setTimeout(() => search(query), 300);
return () => clearTimeout(id);
}, [query]);
7. Object Dependency with useMemo
const options = useMemo(() => ({ limit, offset }), [limit, offset]);
useEffect(() => {
fetchData(options);
}, [options]);
8. Function Dependency with useCallback
const load = useCallback(() => {
fetchData(id);
}, [id]);
useEffect(() => {
load();
}, [load]);
9. Abort Controller
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal }).then(setData);
return () => controller.abort();
}, [url]);
10. Multiple Dependencies
useEffect(() => {
fetchData(userId, token);
}, [userId, token]);
Visual
Dependency Array Behavior
┌─────────────────────────────────────────────────────────────┐
│ NO ARRAY │
│ useEffect(() => { ... }) │
│ Runs: after every render │
│ │
│ EMPTY ARRAY │
│ useEffect(() => { ... }, []) │
│ Runs: once on mount │
│ │
│ WITH VALUES │
│ useEffect(() => { ... }, [a, b]) │
│ Runs: on mount + when a or b changes │
│ │
└─────────────────────────────────────────────────────────────┘
Stale Closure
┌─────────────────────────────────────────────────────────────┐
│ RENDER 1: count = 0 │
│ Effect runs, captures count = 0 │
│ Interval created, closure holds 0 │
│ │
│ RENDER 2: count = 1 │
│ Effect does NOT re-run (empty deps) │
│ Interval still holds 0 │
│ │
│ RENDER 3: count = 2 │
│ Effect does NOT re-run │
│ Interval still holds 0 │
│ │
│ The interval logs 0 forever. │
│ The closure is stale. │
│ │
└─────────────────────────────────────────────────────────────┘
Infinite Loop
┌─────────────────────────────────────────────────────────────┐
│ useEffect(() => { │
│ setCount(count + 1); │
│ }, [count]); │
│ │
│ Render → Effect → setCount → count changes → Render → │
│ Effect → setCount → count changes → Render → ... │
│ │
│ The loop never ends. │
│ │
│ FIX: setCount((c) => c + 1) with [] │
│ │
└─────────────────────────────────────────────────────────────┘
Ref Bridge
┌─────────────────────────────────────────────────────────────┐
│ const ref = useRef(count); │
│ │
│ useEffect(() => { │
│ ref.current = count; │
│ }, [count]); │
│ │
│ useEffect(() => { │
│ const id = setInterval(() => { │
│ console.log(ref.current); │
│ }, 1000); │
│ return () => clearInterval(id); │
│ }, []); │
│ │
│ The ref always holds the latest count. │
│ The interval is created once. │
│ The interval reads the latest value through the ref. │
│ │
└─────────────────────────────────────────────────────────────┘
What Goes in the Array
┌─────────────────────────────────────────────────────────────┐
│ REACTIVE (include) │
│ ├── props.userId │
│ ├── state.count │
│ ├── variable = compute() │
│ └── function handleClick() │
│ │
│ STABLE (exclude) │
│ ├── ref (useRef) │
│ ├── setState │
│ ├── dispatch (useReducer) │
│ └── constant outside component │
│ │
│ MEMOIZED (include the memoized value) │
│ ├── useMemo result │
│ └── useCallback function │
│ │
└─────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| No array | Runs after every render |
| Empty array | Runs once on mount |
| With values | Runs when values change |
| Comparison | Object.is per element |
| Include | Props, state, functions, variables |
| Exclude | Refs, setters, dispatch, constants |
| Stale closure | Missing dependency |
| Infinite loop | Effect updates its dependency |
| Fix for loop | Functional update or remove |
| Fix for stale | Add dependency or use ref |
| Object dependency | useMemo |
| Function dependency | useCallback |
| Lint rule | exhaustive-deps |
Key takeaways:
- The dependency array lists the reactive values the effect reads. React compares the array to the previous array with
Object.is. If any element differs, the effect re-runs. An effect that reads a value without listing it is a bug. - The empty array means “run once.” The effect runs on mount and never again. This is correct only when the effect reads nothing that can change. An effect that reads a value with an empty array has a stale closure.
- The stale closure is the effect reading an old value. The effect captures the values from the render in which it ran. If a value is not in the dependencies, the effect keeps reading the old value. Add the value to the dependencies or use a ref to bridge the latest value.
- The infinite loop is the effect updating a value in its dependencies. The effect runs, updates the value, which triggers the effect again. The fix is the functional update form, which reads the current value without the effect depending on it.
- The comparison is
Object.is, not deep equality. A new object or array on every render is a new value and causes the effect to re-run. UseuseMemoto stabilize objects anduseCallbackto stabilize functions. - Refs, setters, and dispatch are stable. They do not need to be in the dependency array. The ref object is stable, but
ref.currentis not tracked. The effect that readsref.currentdoes not re-run when the ref changes. - The lint rule enforces the rules. The
exhaustive-depswarning is a correctness check. Address it, or suppress it with a comment that explains why the warning is a false positive.
Remember: The dependency array is the contract between the effect and React. The effect declares what it depends on, and React re-runs it when those things change. The contract must be honest: include everything the effect reads. The stale closure and the infinite loop are the two ways the contract is violated, and both are subtle. Use a ref to bridge the latest value to a stable effect. Use useMemo and useCallback to stabilize objects and functions. And remember that the eslint-plugin-react-hooks rule is there for a reason: the warning is the bug, and the fix is the dependency.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!