| |

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 (useRef returns a stable object)
  • Setter functions from useState (React guarantees they are stable)
  • The dispatch function from useReducer
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

ArrayEffect Runs
OmittedAfter 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

ValueIn Array?
PropYes
StateYes
Function from component bodyYes
Variable from component bodyYes
useRef objectNo
setState functionNo
dispatch from useReducerNo
Constant outside componentNo
useCallback memoized functionYes

Comparison

ComparisonResult
Object.is(1, 1)true
Object.is({}, {})false
Object.is(a, a)true
New object each renderEffect re-runs

Common Bugs

BugCauseFix
Stale closureMissing dependencyAdd to array or use ref
Infinite loopEffect updates its dependencyFunctional update or remove
Effect runs too oftenNew object in dependenciesuseMemo
Effect runs once with old dataEmpty array with readsAdd dependencies
Re-subscribe on every renderFunction in dependenciesuseCallback

Escape Hatches

HatchUse When
RefNeed latest value without re-running
useCallbackFunction dependency is unstable
Functional updateState update based on previous
eslint-disable-next-lineIntent 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

PitfallWhy It HappensFix
Stale closureMissing dependencyAdd to array
Infinite loopEffect updates dependencyFunctional update
Runs on every renderNew object in depsuseMemo
Re-subscribes constantlyFunction in depsuseCallback
Lint warning ignoredMisunderstandingAddress or document
Effect runs twiceStrictModeExpected in development
Ref does not triggerRefs are not reactiveUse 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

ItemValue
No arrayRuns after every render
Empty arrayRuns once on mount
With valuesRuns when values change
ComparisonObject.is per element
IncludeProps, state, functions, variables
ExcludeRefs, setters, dispatch, constants
Stale closureMissing dependency
Infinite loopEffect updates its dependency
Fix for loopFunctional update or remove
Fix for staleAdd dependency or use ref
Object dependencyuseMemo
Function dependencyuseCallback
Lint ruleexhaustive-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. Use useMemo to stabilize objects and useCallback to 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.current is not tracked. The effect that reads ref.current does not re-run when the ref changes.
  • The lint rule enforces the rules. The exhaustive-deps warning 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!