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.