Topic 7.2
Effect Dependencies
In one line
The dependency array tells React which values an effect reads, so it can skip re-running the effect when none of them changed; React compares each one with Object.is, which is why new objects and functions made during render re-run the effect every time.
Think of it like this
A phone that re-downloads your email. It would be silly to re-download everything every second. Instead it checks: "has anything changed since last time?" If not, it skips the work. The dependency array is the list of things React checks before re-running your effect.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Dependency array
- The second argument of
useEffect: the list of values the effect reads. - Dependency
- One value in that list. When it changes, the effect re-runs.
- Object.is
- The comparison React uses: same primitive value, or the very same object in memory.
- Reference
- Where an object lives in memory. Two objects with the same contents have different references.
- Reactive value
- A value declared in the component that can change between renders: props, state and values made from them.
- exhaustive-deps
- The ESLint rule that checks every reactive value an effect reads is in its dependency array.
- Stale value
- An old value an effect keeps using because it wasn't re-run when the value changed.
- Updater function
- Passing a function to a state setter,
setCount((c) => c + 1), so you don't need to read the current state.
Step by step
01Start without an array
With no second argument, the effect runs after every single render. Correct, but often wasteful: a re-render caused by something unrelated re-runs it.
useEffect(() => {
console.log("searching", query);
}); // after every render02List what the effect reads
The effect reads query, a prop. Put it in the array. Now React compares query with the previous render's query and skips the effect when they are equal.
useEffect(() => {
console.log("searching", query);
}, [query]); // only when query changes03React compares with Object.is
After each render React walks the array position by position. If every entry is Object.is-equal to last time, the effect (and its cleanup) is skipped.
04Let the linter check you
Leave a value out and exhaustive-deps tells you, with the exact names. This is real ESLint output for an effect that reads roomId and serverUrl but declares [].
$ npx eslint src/ChatRoom.jsx
src/ChatRoom.jsx
11:6 warning React Hook useEffect has missing dependencies: 'roomId' and 'serverUrl'. Either include them or remove the dependency array react-hooks/exhaustive-deps
✖ 1 problem (0 errors, 1 warning)05Watch out for objects made during render
An object literal in the component body is a new object every render, so it is never equal to last time. The linter catches this too.
$ npx eslint src/Search.jsx
src/Search.jsx
16:9 warning The 'options' object makes the dependencies of useEffect Hook (at line 19) change on every render. Move it inside the useEffect callback. Alternatively, wrap the initialization of 'options' in its own useMemo() Hook react-hooks/exhaustive-deps06Remove a dependency by changing the code
To drop count from an interval's dependencies, stop reading it: use the updater form. The effect now reads nothing reactive, so [] is honest and the linter agrees.
useEffect(() => {
const id = setInterval(() => {
setCount((c) => c + 1); // no need to read count
}, 1000);
return () => clearInterval(id);
}, []); // honest: the effect reads no reactive valuesTry it yourself
- 1
Add text to the dependencies
In the first example, change
[count]to[count, text]. Predict the log after clickingtextbefore you run it. (The third effect now runs too.) - 2
Depend on the field, not the object
In the Object.is example, change the dependency from
[user]to[user.name]. Predict: does Copy user still re-run the effect? (No:"Asha"equals"Asha".) - 3
Fix BadSearch two ways
First, change its array to
[options.query]and move nothing. Then try movingoptionsinside the effect with[query]. Both stop the extra runs. Which one would the linter prefer? (Moving it inside: then the effect reads onlyquery.)
Code & diagrams
All three run after the first render. Changing `count` re-runs the first and third. Changing `text` re-runs only the one with no array. Click the buttons yourself to see more.
Preview
Console
Nothing logged yet.Expected console output
no array: runs after every render
[]: runs once, after the first render
[count]: count is 0
--- click count
no array: runs after every render
[count]: count is 1
--- click text
no array: runs after every renderA re-render that keeps the same `user` object skips the effect. A copy with identical contents is a new reference, so the effect runs again even though nothing visible changed.
Preview
Console
Nothing logged yet.Expected console output
Object.is(3, 3): true
Object.is("a", "a"): true
Object.is({}, {}): false
Object.is(NaN, NaN): true
effect: user is Asha
--- re-render, same user object
--- new object with the same contents
effect: user is AshaToggling dark mode re-renders both children. `BadSearch` gets a fresh `options` object each time, so its effect re-runs for nothing. `GoodSearch` depends on the string and stays quiet. In a real app that wasted effect would be a network request.
Preview
Console
Nothing logged yet.Expected console output
BadSearch effect: searching react
GoodSearch effect: searching react
--- toggle 1 (query did not change)
BadSearch effect: searching react
--- toggle 2 (query did not change)
BadSearch effect: searching reactBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Leave a dependency out
Write an effect that logs "searching " + query with an empty array [], in a component whose query prop changes.
Break #2
Set state from an object dependency
In BadSearch, add const [n, setN] = useState(0); and call setN(n + 1) inside the effect.
Myth vs fact
Myth
[] means "run on mount" and is a good default.
Fact
[] means "this effect reads no reactive values". If it does read props or state, [] is a bug that gives stale values. Let the code decide the array.
Myth
React compares dependencies deeply.
Fact
It uses Object.is, one entry at a time: objects and arrays are equal only if they are the same reference.
Myth
You choose the dependencies.
Fact
The code chooses them: every reactive value the effect reads must be listed. To change the list, change what the effect reads.
Myth
State setters must be in the array.
Fact
Setters from useState and dispatch from useReducer are stable (the same function every render). The linter knows this and doesn't ask for them; listing them is harmless.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
React stores the previous dependency array on the effect's hook object in the fiber and runs
areHookInputsEqual, a loop ofObject.ischecks. Changing the length of the array between renders makes React warn in development, because positions no longer line up. - ▸
useMemoanduseCallbackcan stabilise an object or function so it can be a dependency, but prefer restructuring first: moving the value inside the effect removes a dependency instead of memoising it. The React Compiler (Phase 16) memoises such values automatically. - ▸
exhaustive-depsalso understands refs: it doesn't ask forrefobjects (they're stable), but it warns when a cleanup readsref.current, because by then the ref may point elsewhere. Copyref.currentinto a variable inside the effect. - ▸
eslint-plugin-react-hooks 7 adds compiler-powered rules (purity, refs during render, setState in effects) in its
recommendedpreset alongsiderules-of-hooksandexhaustive-deps.
Remember this
- 1
useEffect(setup, deps)has three forms. No array: the effect runs after every render. **Empty array[]: it runs after the first render only (and cleans up when the component is removed). An array of values[a, b]**: it runs after the first render, and after any render whereaorbis different from last time. - 2
"Different" is decided with **
Object.is(previous, next), one dependency at a time. For primitives** (numbers, strings, booleans,null,undefined) that means "same value":"react"equals"react". For objects, arrays and functions it means "the very same object in memory" (same reference). Two objects with identical contents are still different.Object.isis like===, exceptNaNequalsNaNand+0differs from-0. - 3
The rule: every reactive value the effect reads must be in the array. A reactive value is anything declared inside the component that can change between renders: props, state, and variables or functions calculated from them. Values declared outside the component (constants, imported functions) and the setters from
useState(which never change) don't need to be listed. - 4
You don't have to check this by eye. The **
react-hooks/exhaustive-deps** lint rule (fromeslint-plugin-react-hooks, included in Vite and Next.js templates) reads your effect and warns about any missing or unneeded dependency. Treat its warnings as bugs. If you leave a value out, the effect keeps using the value from the render where it last ran: a stale value. - 5
The classic trap is an object or function created during render used as a dependency. A new object is made on every render, so
Object.issays "changed" every time and the effect re-runs after every render. If that effect also sets state, you get an endless loop. Fixes: create the object inside the effect, depend on its primitive fields (options.queryinstead ofoptions), or move it outside the component if it doesn't use props or state. - 6
Remove a dependency honestly: don't delete it from the array and hide the warning. Change the code so the effect no longer *reads* it. Move constants outside the component. Move helper functions and objects inside the effect. Replace
setCount(count + 1)with the updater formsetCount((c) => c + 1), socountisn't read. For a value you want to read but not react to, useuseEffectEvent(Topic 7.9). The dependencies always follow the code, never the other way round.
Explain it without notes
Explain the three forms of the dependency array and when the effect runs for each.
How does React decide whether a dependency changed? What consequences does that have for objects and functions?
What is a reactive value? Which values don't need to be dependencies?
The linter asks you to add a dependency, but adding it makes the effect re-run too often. What do you do?
Practice
This effect re-runs after every render. Fix it without disabling the linter:
``jsx
function Room({ roomId }) {
const config = { roomId, retries: 3 };
useEffect(() => {
console.log("connect", config.roomId, config.retries);
}, [config]);
return null;
}
Make this ticker's dependency array honest and empty: useEffect(() => { const id = setInterval(() => setCount(count + 1), 1000); return () => clearInterval(id); }, [count]);
Trade-offs
- ↔
Listing every dependency is always correct but can cause extra runs; restructuring to read fewer values costs a little thought and pays off in fewer bugs.
- ↔
useMemo/useCallbackto stabilise dependencies adds code and memory; moving values inside the effect is usually simpler.
Done when you can
I can predict when an effect runs with no array,
[]and[a, b].I can explain
Object.isand why new objects re-run effects.I can list an effect's reactive values myself and trust the linter.
I can remove a dependency by changing code, not by hiding the warning.
I can fix an effect loop caused by an object or function dependency.