useEffect and the dependency array: why this hook confuses everyone

If you’ve worked with React for more than a few weeks, you’ve probably had this moment: the component renders in an infinite loop, or the data on screen is stale, or ESLint is screaming about missing dependencies and you have no idea why.

The culprit, most of the time, is a useEffect with a poorly configured dependency array.

This hook is one of the most powerful in React. It’s also one that causes the most silent bugs in production. Not because it’s poorly designed, but because it operates on a different mental model than most developers expect when they first pick it up.

In this post I’ll show you how useEffect actually works, the most common mistakes I see in code review, and how to think about dependencies in a way that holds up even in complex components.


The wrong mental model most people carry

Before talking about the dependency array, I need to address the mental model mistake that sits at the root of almost every useEffect bug.

Most people read this code like this:

useEffect(() => {
  fetchUser(userId);
}, [userId]);

And think: “run this when userId changes”.

That reading isn’t wrong, but it’s incomplete. And the incompleteness is what causes the bugs.

The correct mental model is: “run this effect after every render where userId has a different value than the previous render.”

The difference seems subtle, but it changes everything:

  • The effect always runs on the first render, regardless of what’s in the array
  • React compares the values in the array between renders — it doesn’t “watch” variables
  • If the component unmounts and remounts, the effect runs again even if userId hasn’t changed

With this correct model, a lot of things that seemed like magic start making sense.


What the dependency array actually is

The dependency array is not a list of “triggers”. It’s a list of values that the effect uses and that can change between renders.

Think of it this way: inside the function you pass to useEffect, which values external to the effect are you reading? Those values need to be in the array.

useEffect(() => {
  // Uses: userId, token, endpoint
  // All of them need to be in the array
  fetch(`${endpoint}/users/${userId}`, {
    headers: { Authorization: token }
  });
}, [userId, token, endpoint]);

When you omit a value the effect uses, you’re creating a stale closure — the effect captures the old value and never updates. This is one of the hardest bugs to track down because the component appears to work, but with outdated data.


The three forms of the array and when to use each

Array with dependencies: [a, b, c]

useEffect(() => {
  document.title = `${count} notifications`;
}, [count]);

Runs on mount and every time any dependency changes. Use this when the effect needs to stay in sync with values from the component.

Empty array: []

useEffect(() => {
  analytics.pageView(window.location.pathname);
}, []);

Runs only once, on mount. Use this for effects that represent “the component appeared”: analytics calls, subscriptions, initialization of external libraries.

One caveat: empty array doesn’t mean “run once and that’s it” in an absolute sense. If the component unmounts and remounts (for example, due to a route change), the effect runs again.

No array

useEffect(() => {
  console.log('rendered');
});

Runs after every single render, no exceptions. This is rarely what you want in production. Useful for debugging, but dangerous to leave in production code due to performance issues and unexpected side effects.


The most common bugs — and how to fix them

Bug 1: infinite loop caused by object or array in the dependency array

This is the bug that makes the component render forever and freeze the browser.

// BUG: infinite loop
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  const options = { include: ['address', 'phone'] }; // recreated on every render

  useEffect(() => {
    fetchUser(userId, options).then(setUser);
  }, [userId, options]); // options changes every time -> loop
}

The problem: options is an object literal created inside the component. On every render, a new object is created — even with the same content, the reference is different. React compares dependencies with Object.is, which for objects compares reference, not value.

// CORRECT: move it outside the component if it's constant
const OPTIONS = { include: ['address', 'phone'] };

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetchUser(userId, OPTIONS).then(setUser);
  }, [userId]); // OPTIONS never changes, doesn't need to be in the array
}

Or if the object needs to be dynamic, use useMemo:

function UserProfile({ userId, includeFields }) {
  const options = useMemo(
    () => ({ include: includeFields }),
    [includeFields]
  );

  useEffect(() => {
    fetchUser(userId, options).then(setUser);
  }, [userId, options]);
}

Bug 2: stale closure — outdated data

This is the silent bug. The component works, but shows old data.

// BUG: stale closure
function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const interval = setInterval(() => {
      console.log(count); // always prints 0 — captured the initial value
      setCount(count + 1); // never goes past 1
    }, 1000);

    return () => clearInterval(interval);
  }, []); // count is not in the array
}

The useEffect with [] captures the value of count at mount time (0) and never updates that reference. The interval keeps reading count = 0 forever.

The solution depends on what you need:

// OPTION 1: add count to the dependencies
// (recreates the interval on every change — can be inefficient)
useEffect(() => {
  const interval = setInterval(() => {
    setCount(count + 1);
  }, 1000);

  return () => clearInterval(interval);
}, [count]);

// OPTION 2: use the functional form of setState (preferred here)
// doesn't need to read count, so it doesn't need to be in the array
useEffect(() => {
  const interval = setInterval(() => {
    setCount(prev => prev + 1);
  }, 1000);

  return () => clearInterval(interval);
}, []);

The functional form of setState (prev => prev + 1) is powerful precisely because you don’t need to capture the current state — React passes the most recent value as an argument.

Bug 3: function recreated on every render in the dependency array

Similar to the object problem, functions defined inside the component have a new reference on every render.

// BUG: fetchData is recreated on every render -> effect always runs
function SearchResults({ query }) {
  const fetchData = async () => { // new reference on every render
    const data = await search(query);
    setResults(data);
  };

  useEffect(() => {
    fetchData();
  }, [fetchData]); // infinite loop
}

Fix with useCallback:

function SearchResults({ query }) {
  const fetchData = useCallback(async () => {
    const data = await search(query);
    setResults(data);
  }, [query]); // fetchData only changes when query changes

  useEffect(() => {
    fetchData();
  }, [fetchData]);
}

Or, simpler: move the function inside the useEffect. If the function is only used inside the effect, it doesn’t need to exist outside of it:

function SearchResults({ query }) {
  useEffect(() => {
    const fetchData = async () => {
      const data = await search(query);
      setResults(data);
    };

    fetchData();
  }, [query]); // only query matters
}

Bug 4: ignoring ESLint and suppressing the warning

This one deserves special attention because it’s an active decision, not an accident.

useEffect(() => {
  updateChart(data, config);
  // eslint-disable-next-line react-hooks/exhaustive-deps
}, [data]); // config was intentionally omitted

Most of the time, when a developer suppresses this warning, the reason is one of two things: config changes too often and causes extra renders, or the developer is convinced that config “doesn’t need” to be in the array.

The problem is that suppressing the warning doesn’t fix the underlying issue — it hides it. If config genuinely shouldn’t trigger a re-run of the effect, the correct solution is to use useRef to store the latest value without causing a re-render:

function Chart({ data, config }) {
  const configRef = useRef(config);

  useEffect(() => {
    configRef.current = config;
  });

  useEffect(() => {
    // reads the latest config via ref, without depending on it in the array
    updateChart(data, configRef.current);
  }, [data]);
}

This is rarely needed. In the vast majority of cases, ESLint is right and the suppression is hiding a real bug.


The cleanup function: what happens when the effect runs again

Every time an effect with dependencies runs again, React first executes the cleanup function from the previous effect. Understanding this prevents a lot of subtle bugs.

useEffect(() => {
  const subscription = subscribe(channel);

  return () => {
    subscription.unsubscribe(); // cleanup
  };
}, [channel]);

When channel changes:

  1. React runs the cleanup from the previous effect (unsubscribe from the old channel)
  2. React runs the new effect (subscribe to the new channel)

Without the cleanup, you accumulate subscriptions. After 10 channel changes, you have 10 active subscriptions at the same time — a classic memory leak.

The same pattern applies to setTimeout, setInterval, addEventListener, and any resource that needs to be released:

useEffect(() => {
  const handler = (e) => handleKeyPress(e);
  window.addEventListener('keydown', handler);

  return () => window.removeEventListener('keydown', handler);
}, [handleKeyPress]);

Race conditions in async requests

This is a bug that shows up in production when the user interacts quickly. You fire a request, the user changes something before the response arrives, a second request goes out — and the first response arrives after the second one, overwriting the correct result with stale data.

// BUG: race condition
useEffect(() => {
  fetchUser(userId).then(data => {
    setUser(data); // may overwrite a more recent result
  });
}, [userId]);

The fix is to use a cancellation flag in the cleanup:

useEffect(() => {
  let cancelled = false;

  fetchUser(userId).then(data => {
    if (!cancelled) {
      setUser(data);
    }
  });

  return () => {
    cancelled = true;
  };
}, [userId]);

Or with AbortController to actually cancel the request, saving bandwidth:

useEffect(() => {
  const controller = new AbortController();

  fetch(`/api/users/${userId}`, { signal: controller.signal })
    .then(res => res.json())
    .then(data => setUser(data))
    .catch(err => {
      if (err.name !== 'AbortError') {
        setError(err);
      }
    });

  return () => controller.abort();
}, [userId]);

When useEffect is not the right tool

Part of the confusion with useEffect comes from using it for things it wasn’t designed to do.

Data transformations don’t need useEffect:

// WRONG: useEffect to derive state
const [fullName, setFullName] = useState('');

useEffect(() => {
  setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);

// CORRECT: calculate during render
const fullName = `${firstName} ${lastName}`;

Adding a useEffect here creates an unnecessary extra render: first render with fullName empty, then the effect runs and updates it, triggering a second render.

Event handlers don’t need useEffect:

// WRONG
useEffect(() => {
  if (submitted) {
    sendForm(formData);
  }
}, [submitted]);

// CORRECT: logic goes in the handler
const handleSubmit = () => {
  sendForm(formData);
  setSubmitted(true);
};

The practical rule: if you can calculate something during render, don’t use useEffect. If you need to synchronize with something outside React (API, DOM, timer, subscription), useEffect is the right place.


Conclusion: useEffect asks for a different mental model

useEffect confuses people because most arrive at it with an “event” mental model — something that fires when a specific thing happens. But the hook was designed for “synchronization” — keeping external effects in sync with component state.

With the correct model, the dependency array stops being something you try to trick and becomes a tool you use with intention. You’re not saying “run when this changes” — you’re saying “these are all the values my effect uses, and React will ensure the effect is always synchronized with them.”

The bugs disappear when you stop fighting ESLint and start understanding what it’s signaling. When the warning says a dependency is missing, it’s saying your effect may be reading a stale value. Almost always, it’s right.

📚 Next steps The official React docs have an excellent section called “You Might Not Need an Effect” that’s well worth reading. And if you want to go deeper into the synchronization model, look up Dan Abramov’s “A Complete Guide to useEffect” — it remains the most thorough reference on the subject.