Command Palette

Search for a command to run...

Hectal
PHASE 7Intermediate React 16.8+ ~23 min· topic 2 of 9

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.

Search.jsxwhole filejsx
useEffect(() => {
  console.log("searching", query);
}); // after every render

02List 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.

Search.jsxwhole filejsx
useEffect(() => {
  console.log("searching", query);
}, [query]); // only when query changes

03React 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.

React compares with Object.isdiagram
Rendering diagram…

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 [].

Terminalwhole filebash
$ 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.

Terminalwhole filebash
$ 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-deps

06Remove 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.

Ticker.jsxwhole filejsx
useEffect(() => {
  const id = setInterval(() => {
    setCount((c) => c + 1); // no need to read count
  }, 1000);
  return () => clearInterval(id);
}, []); // honest: the effect reads no reactive values

Try it yourself

  1. 1

    Add text to the dependencies

    In the first example, change [count] to [count, text]. Predict the log after clicking text before you run it. (The third effect now runs too.)

  2. 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. 3

    Fix BadSearch two ways

    First, change its array to [options.query] and move nothing. Then try moving options inside the effect with [query]. Both stop the extra runs. Which one would the linter prefer? (Moving it inside: then the effect reads only query.)

Code & diagrams

No array, empty array, and [count] New tab

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

Press Run to see the page.

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 render
Object.is decides what "changed" means New tab

A 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

Press Run to see the page.

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 Asha
An object made during render re-runs the effect every time New tab

Toggling 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

Press Run to see the page.

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 react

Break 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.

terminal
$ npx eslint + Console
── what you'll see ──
warning React Hook useEffect has a missing dependency: 'query'. Either include it or remove the dependency array react-hooks/exhaustive-deps
 
Console: "searching react" once, never again, even after query becomes "redux".

Break #2

Set state from an object dependency

In BadSearch, add const [n, setN] = useState(0); and call setN(n + 1) inside the effect.

terminal
$ Console
── what you'll see ──
Maximum update depth exceeded. This can happen when a component calls setState inside useEffect, but useEffect either doesn't have a dependency array, or one of the dependencies changes on every render.

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 of Object.is checks. Changing the length of the array between renders makes React warn in development, because positions no longer line up.

  • ▸

    useMemo and useCallback can 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-deps also understands refs: it doesn't ask for ref objects (they're stable), but it warns when a cleanup reads ref.current, because by then the ref may point elsewhere. Copy ref.current into a variable inside the effect.

  • ▸

    eslint-plugin-react-hooks 7 adds compiler-powered rules (purity, refs during render, setState in effects) in its recommended preset alongside rules-of-hooks and exhaustive-deps.

Remember this

  1. 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 where a or b is different from last time.

  2. 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.is is like ===, except NaN equals NaN and +0 differs from -0.

  3. 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. 4

    You don't have to check this by eye. The **react-hooks/exhaustive-deps** lint rule (from eslint-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. 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.is says "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.query instead of options), or move it outside the component if it doesn't use props or state.

  6. 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 form setCount((c) => c + 1), so count isn't read. For a value you want to read but not react to, use useEffectEvent (Topic 7.9). The dependencies always follow the code, never the other way round.

Explain it without notes

01

Explain the three forms of the dependency array and when the effect runs for each.

02

How does React decide whether a dependency changed? What consequences does that have for objects and functions?

03

What is a reactive value? Which values don't need to be dependencies?

04

The linter asks you to add a dependency, but adding it makes the effect re-run too often. What do you do?

Practice

01

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; }

02

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/useCallback to 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.is and 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.