Ch. 3 · React

React Effect Dependencies and Synchronization

How the useEffect dependency array really works: Object.is comparison, why objects re-run effects, and fixing deps without lint suppression.

~6 min readintermediateupdated Oct 6, 2026

“What goes in the dependency array, and why does my effect run on every render even though I listed only one value?” This is one of the most common React interview questions because nearly every production codebase has a bug in this area: an effect that reconnects constantly, a fetch that never refreshes, or a timer that reads a stale value.

Interviewers want to hear that dependencies are not a list of “things that should trigger the effect”. They are a description of what the effect reads, and React uses them to decide when the effect is out of sync with the latest render.

Before you start

You should know how to write useEffect(setup, deps) and that a cleanup function can be returned. You should understand closures and the difference between primitive equality and reference equality in JavaScript ({} === {} is false). This applies to React 18 and 19. If your project uses the React Compiler (React 19 era), many objects and functions become stable automatically, but the rule about what counts as a dependency is unchanged.

The short answer

The dependency array must contain every reactive value the effect reads: props, state, and anything declared in the component body that is derived from them. After each render, React compares each dependency with its previous value using Object.is; if any differ, it runs the cleanup and then the effect again. No array means “after every render”, an empty array means “only on mount” (because nothing reactive is read). If you want an effect to run less often, do not delete a dependency; restructure the code so the effect no longer needs it.

How it works

React stores the previous dependency array and checks each slot:

function depsChanged(prev, next) {
  if (prev === null) return true;
  for (let i = 0; i < next.length; i++) {
    if (!Object.is(prev[i], next[i])) return true;
  }
  return false;
}
JavaScript

Object.is treats numbers and strings by value, but objects, arrays and functions by reference. That is the root of the “runs every render” problem:

function Chat({ roomId }) {
  const options = { roomId, serverUrl: 'https://chat.example' }; // new object each render

  useEffect(() => {
    const conn = createConnection(options);
    conn.connect();
    return () => conn.disconnect();
  }, [options]); // changes every render
}
JSX

Any render, even one caused by typing in an unrelated input, creates a new options object, so the chat reconnects on every keystroke.

The react-hooks/exhaustive-deps lint rule checks that you listed everything you read. It cannot tell you whether a value is stable; that part is your design.

Step-by-step walkthrough

Step 1: List what the effect reads

Read the effect body and underline every identifier that comes from the component scope. Values from outside the component (module constants, imported functions) are not reactive and never need listing. State setters from useState and dispatch from useReducer are guaranteed stable, so the linter accepts leaving them out, though including them is harmless.

Step 2: Move objects inside the effect or outside the component

If an object is only used by the effect, create it inside:

useEffect(() => {
  const options = { roomId, serverUrl: SERVER_URL };
  const conn = createConnection(options);
  conn.connect();
  return () => conn.disconnect();
}, [roomId]);
JSX

Now the only dependency is the primitive roomId, which changes exactly when you want a reconnect. If an object never depends on props or state, move it to module scope.

Step 3: Depend on primitives, not whole objects

If a parent passes an object prop, destructure the primitive fields outside the effect and depend on those:

function Chat({ options }) {
  const { roomId, serverUrl } = options;
  useEffect(() => {
    const conn = createConnection({ roomId, serverUrl });
    conn.connect();
    return () => conn.disconnect();
  }, [roomId, serverUrl]);
}
JSX

Step 4: Separate “react to this” from “read the latest of that”

Sometimes the effect must read a value without re-running when it changes, such as logging the current theme when a connection opens. In React 19.2 and later, useEffectEvent provides this: an event function that always sees the latest props and state but is not a dependency. On earlier versions, the common workaround is to keep the latest value in a ref updated after each render. Use either sparingly; most of the time, the value genuinely is a dependency.

Worked scenario

An analytics dashboard fetches metrics for a date range:

function Metrics({ teamId }) {
  const [range, setRange] = useState({ from: '2026-09-01', to: '2026-09-30' });
  const [data, setData] = useState(null);

  useEffect(() => {
    fetchMetrics(teamId, range).then(setData);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);
}
JSX

Someone added the lint suppression because including range “caused an infinite loop”. Now changing the team or the range does nothing; the dashboard shows the first team forever.

The loop came from a different bug: elsewhere, setRange({ ...range }) was called in an effect, producing a new object each time. The real fix is to depend on primitives and handle races:

const { from, to } = range;

useEffect(() => {
  let ignore = false;
  fetchMetrics(teamId, { from, to }).then(d => { if (!ignore) setData(d); });
  return () => { ignore = true; };
}, [teamId, from, to]);
JSX

And the stray effect that copied range into itself was deleted. If you are chasing the loop itself, see maximum update depth exceeded.

Common mistake

  • “I only want it to run once, so I pass [].” If the effect reads props or state, [] freezes it to the first render’s values. Either the value is truly constant (move it outside) or the effect must re-run.
  • Suppressing the lint rule. It nearly always converts a visible re-run into an invisible stale-closure bug.
  • Wrapping everything in useMemo and useCallback to calm the array. Sometimes right, but moving the object into the effect is simpler and has no dependency list of its own.
  • Thinking deep equality is used. It is not; two equal-looking objects are different dependencies.
  • Using an effect to respond to an event. If code should run because the user clicked, put it in the click handler. Effects are for synchronising with something external.

Verify the behavior

Log every effect run and type in an unrelated input:

const [draft, setDraft] = useState('');
const options = { roomId };

useEffect(() => {
  console.log('effect ran for', roomId);
}, [options]);

return <input value={draft} onChange={e => setDraft(e.target.value)} />;
JSX

Each keystroke logs effect ran for general. Change the dependency to [roomId] and the log appears only on mount (twice in Strict Mode development) and when the room changes. That single experiment demonstrates reference comparison more convincingly than any explanation.

Follow-up questions

What happens with no dependency array? The effect runs after every committed render. This is occasionally useful for syncing something cheap, but usually signals a missing array.

Are refs dependencies? The ref object from useRef is stable, so the linter does not require it. Reading ref.current is not reactive: changes to it do not re-run anything.

Why does the linter want a function prop in the array? Because the effect calls it, and a new function might close over different values. Stabilise it in the parent with useCallback, or call it from an event function. See callback dependencies.

How does this relate to infinite loops? An effect that sets state used in its own dependencies, without a condition, re-runs forever. The too many re-renders guide covers the render-phase variant.

Interview exercise

This component reconnects every second. Explain why and fix it without suppressing the linter.

function Room({ roomId }) {
  const [now, setNow] = useState(Date.now());
  useEffect(() => {
    const id = setInterval(() => setNow(Date.now()), 1000);
    return () => clearInterval(id);
  }, []);

  const config = { roomId, retries: 3 };
  useEffect(() => {
    const c = connect(config);
    return () => c.close();
  }, [config]);
}
JSX

Answer and reasoning

The clock updates state every second, which re-renders Room. Each render builds a new config object, so Object.is reports a change and the connection effect cleans up and reconnects. The values inside config did not change, only its identity did. Fix it by creating the object inside the effect and depending on the primitive: useEffect(() => { const c = connect({ roomId, retries: 3 }); return () => c.close(); }, [roomId]);. A cleaner design also moves the clock into its own small component so the room is not re-rendered every second at all.

Continue learning

Keep going with the React interview questions and the React MCQs. Related notes: effect cleanup, callback dependencies and derived values without extra state. Official reference: Lifecycle of Reactive Effects and Removing Effect Dependencies on react.dev.

More in React

read ✓React · mid

React StrictMode and Double-Invoked Effects

What StrictMode calls twice in development, the exact mount, unmount, mount sequence in React 19, and how to fix the effects it exposes.

~6 min readread →
read ✓React · mid

React UseCallback and Captured Values

How useCallback dependencies decide which state a callback sees, why an empty array freezes stale values, and when useCallback is worth it.

~6 min readread →
esc