React useEffect infinite loop: missing dependency array fix

Programming & Dev Tools Beginner 👁 4 views 📅 Jul 9, 2026

Missing dependency array in useEffect is the top reason for infinite loops. Add an empty array or list deps to stop it.

1. The missing dependency array — most common cause

If you're seeing your React component re-render forever, 99% of the time it's a useEffect without a dependency array. You write something like this:

useEffect(() => {
  fetchData();
});

That's an uncontrolled effect. It runs after every render. If fetchData() sets state, that triggers another render, which runs the effect again, which sets state again — infinite loop.

The fix is simple: add an empty array [] as the second argument if you want it to run only once (on mount):

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

If the effect depends on some variable, list it in the array:

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

Don't bother with useEffect without deps — it's almost never what you want. I see this mistake every week in code reviews. Beginners skip the array because they don't know it's required. The React docs call this a "rule of hooks" but really it's a convention that saves your app from crashing.

2. Stale closures and missing state in dependency array

Another loop trigger: you list some deps but forget a state variable or prop. The effect reads a stale value, updates state, loop starts.

Example — this looks fine but loops:

const [count, setCount] = useState(0);

useEffect(() => {
  setCount(count + 1);
}, [count]); // count is in the array, but it's the cause of the loop

Wait — that's a loop because setCount changes count, which triggers the effect again. This is an intentional loop, but in practice you might do something subtler.

Consider this real scenario from a chat app I fixed last month:

const [messages, setMessages] = useState([]);
const [unread, setUnread] = useState(0);

useEffect(() => {
  const ws = new WebSocket('ws://example.com');
  ws.onmessage = (event) => {
    const newMsg = JSON.parse(event.data);
    setMessages(prev => [...prev, newMsg]);
    setUnread(prev => prev + 1);
  };
  return () => ws.close();
}, []); // only runs once

Here the empty array works because we use functional updates (prev => ...). But if you use messages inside the effect without listing it, you get stale closure — the effect sees the old messages array. If you then try to setMessages(messages.concat(newMsg)) without listing messages in deps, you'll see weird behavior but not necessarily a loop.

The loop happens when you do list a state variable that changes inside the effect. So if you write:

useEffect(() => {
  setCount(count + 1);
}, [count]); // loop

That's your own fault. But sometimes it's indirect — like listing a prop that gets updated by the parent because of this effect.

Fix: Use functional updates for state that depends on previous state. And only list deps that you actually read. If you're not reading a variable, don't list it. Use the ESLint plugin eslint-plugin-react-hooks — it catches missing deps and excess deps.

3. Object or array dependencies that change every render

This one bites everyone eventually. You list a dependency that's an object or array. On each render, a new reference is created, so React thinks the dep changed, and re-runs the effect. Loop again.

Example:

const user = { id: 1, name: 'Alice' };

useEffect(() => {
  fetchProfile(user.id);
}, [user]); // object changes every render because it's a new reference

Every time the component re-renders, user is a new object — even if the values are identical. React compares by reference, not by value.

Fix options:

  • List the primitive values inside the object: [user.id, user.name]
  • Memoize the object with useMemo:
const user = useMemo(() => ({ id: 1, name: 'Alice' }), []);

Same problem with arrays:

const ids = [1, 2, 3];
useEffect(() => {
  fetchItems(ids);
}, [ids]); // new array each render

Fix: useMemo or list the array's length or join it:

useEffect(() => {
  fetchItems(ids);
}, [ids.join(',')]); // string comparison — works but ugly

Better: useMemo. Or better yet, redesign to avoid passing objects into deps. I've seen teams waste hours on this. The React docs mention this but not loudly enough.

Quick-reference summary table

Cause Symptom Fix
Missing dependency array Effect runs on every render Add [] or list deps
State change inside effect with same dep listed Loop on state update Use functional update or remove dep
Object/array as dep creates new reference Effect runs on every render Use useMemo or list primitives
Missing cleanup for subscriptions Multiple listeners accumulate, memory leak Add return function to cleanup
"The empty array is your friend for one-time effects. But don't lie to React — if your effect uses a variable, list it."

Final tip: install and run ESLint with react-hooks/exhaustive-deps rule. It's not perfect but catches 90% of these loops before they hit production. I've caught loops in code reviews that would have crashed a dashboard page — that rule saved the day.

Was this solution helpful?