Effect Dependencies
The dependency array is the contract between your effect and React: it tells React exactly when to re-run the effect so it stays in sync with the rest of your component. Get it right and effects behave predictably. Get it wrong—by omitting a value the effect reads—and you introduce stale closures, where the effect quietly operates on data from a previous render. This page covers the rules, the bugs, the lint rule that catches them, and how to legitimately shrink your dependency list.
What counts as a dependency
A dependency is any reactive value the effect reads from the component body. Reactive values are things that can change between renders: props, state, context values, and any variables or functions derived from them inside the component.
function ChatRoom({ roomId }) { // roomId is a prop — reactive
const [theme, setTheme] = useState("dark"); // theme is state — reactive
useEffect(() => {
const conn = connect(roomId, theme); // reads roomId AND theme
conn.open();
return () => conn.close();
}, [roomId, theme]); // so both must be listed
}
The rule is mechanical: every reactive value referenced inside the effect must appear in the array. Values that are not reactive—module-level constants, imported functions, setState updaters, and refs—are stable and do not need to be listed.
How React compares dependencies
On each render React compares every dependency to its value from the previous render using Object.is (reference equality). If any differ, the effect’s cleanup runs and the effect runs again.
useEffect(() => { /* ... */ }, [userId]); // re-runs only when userId changes
useEffect(() => { /* ... */ }, []); // mount-only (no reactive deps)
useEffect(() => { /* ... */ }); // every render (no array at all)
Because the comparison is by reference, objects and arrays created during render are treated as new every time—a frequent cause of effects that re-run on every render.
Stale closures: the bug you’re avoiding
An effect “closes over” the variables visible when it was created. If you omit a value from the array, the effect won’t re-run when that value changes, so it keeps using the snapshot it captured. This is a stale closure.
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log(count); // always logs the count captured at mount
setCount(count + 1); // sees stale count, stalls at 1
}, 1000);
return () => clearInterval(id);
}, []); // BUG: count is read but not declared
}
Output:
0
1
1
1
1
The interval was created once with count frozen at 0, so count + 1 is forever 1. The effect lied to the dependency array, and the result is a silent logic error rather than a crash.
Fix 1: functional updates
When you only need the previous state to compute the next, use the updater form of the setter. The updater receives the current value, so the effect no longer needs to read count at all—and the dependency disappears honestly.
useEffect(() => {
const id = setInterval(() => {
setCount((c) => c + 1); // no read of count → no dependency
}, 1000);
return () => clearInterval(id);
}, []); // genuinely empty, and correct
Fix 2: move constants out
If a value never depends on a render, hoist it out of the component. Module-level values are stable and need no entry.
const OPTIONS = { reconnect: true }; // defined once, outside the component
function Room({ roomId }) {
useEffect(() => {
const conn = connect(roomId, OPTIONS);
conn.open();
return () => conn.close();
}, [roomId]); // OPTIONS is not reactive, so it's omitted correctly
}
Fix 3: stabilize functions with useCallback
A function defined inside a component is a new reference every render, so listing it re-runs the effect constantly. Wrap it in useCallback so its identity is stable until its dependencies change.
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const fetchResults = useCallback(async () => {
const res = await fetch(`/api/search?q=${query}`);
setResults(await res.json());
}, [query]); // new identity only when query changes
useEffect(() => {
fetchResults();
}, [fetchResults]); // stable across renders where query is unchanged
}
For objects, useMemo plays the same role; for values that should sit outside the dependency graph entirely (like the latest callback prop), see Effect Events.
The exhaustive-deps lint rule
react-hooks/exhaustive-deps from eslint-plugin-react-hooks statically analyzes each effect and flags any reactive value you read but didn’t declare. Treat it as a correctness tool, not a style nag.
| You wrote | Lint says | Right fix |
|---|---|---|
}, []) but read count | ”missing dependency: ‘count‘“ | Add it, or use a functional update |
| Re-creates an object each render | (no warning, but effect re-runs) | Wrap in useMemo or hoist out |
Disabled with // eslint-disable | (silenced) | Almost always wrong—fix the cause |
Never silence exhaustive-deps to “make it run once.” Suppressing the warning hides a stale closure; the correct fix is to remove the dependency by restructuring (functional updates, hoisting,
useCallback)—not by lying.
Best Practices
- List every reactive value the effect reads—props, state, context, and derived functions.
- Don’t list non-reactive values: module constants, refs, and
setStateupdaters are already stable. - Prefer functional updates (
setCount(c => c + 1)) to drop a state dependency honestly. - Wrap effect-dependency functions in
useCallbackand objects inuseMemoto keep references stable. - Hoist render-independent constants and configuration outside the component.
- Keep
react-hooks/exhaustive-depsenabled and fix its warnings instead of disabling them.