The Error
Your browser tab slows to a crawl, then the console fills up with the same line repeating hundreds of times:
Warning: 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.
React hit its internal re-render limit โ typically around 50 nested updates โ and threw the warning to stop a full browser crash. The component is stuck in an endless loop.
Why This Happens
The loop has a brutally simple structure:
- Component renders
useEffectruns and callssetState- State change triggers a re-render
- Re-render causes
useEffectto run again - Back to step 2 โ forever
Three root causes cover 95% of cases in the wild:
- Missing dependency array โ no array means the effect runs after every render, no exceptions
- Object or array in the dependency array โ a new reference is created each render, so React's shallow comparison always sees a change
- Unconditional state update inside the effect โ even a perfectly-formed dependency array won't save you if you always call
setState
Fixes by Case
Case 1: Missing Dependency Array
No array at all is the most common beginner mistake. The effect treats every render as a trigger.
// โ Broken โ runs after every render
useEffect(() => {
setCount(count + 1);
});
// โ
Fixed โ runs only once on mount
useEffect(() => {
setCount(1);
}, []);
That empty [] is not optional sugar โ it's a contract telling React "run this once and stop."
Case 2: Object or Array Dependency Recreated Each Render
JavaScript creates a brand-new object in memory every time a function runs. Two objects { page: 1 } and { page: 1 } are not equal by reference โ React sees them as different, so the effect re-fires.
// โ Broken โ options gets a new reference on every render
const options = { page: 1, limit: 20 };
useEffect(() => {
fetchData(options);
setData(result);
}, [options]); // always "changed"
Two clean solutions:
// โ
Option A: move the object outside the component entirely
const OPTIONS = { page: 1, limit: 20 }; // created once, never changes
function MyComponent() {
useEffect(() => {
fetchData(OPTIONS);
}, []);
}
// โ
Option B: stabilize with useMemo
const options = useMemo(() => ({ page: 1, limit: 20 }), []);
useEffect(() => {
fetchData(options);
}, [options]); // stable reference now
Case 3: Function Dependency Recreated Each Render
Functions have the same reference problem as objects. Define a function inside a component body and it's a new function on every render.
// โ Broken โ fetchUser is a new function every render
const fetchUser = () => {
fetch('/api/user').then(r => r.json()).then(setUser);
};
useEffect(() => {
fetchUser();
}, [fetchUser]); // always triggers
Wrap it in useCallback to get a stable reference:
// โ
Fixed
const fetchUser = useCallback(() => {
fetch('/api/user').then(r => r.json()).then(setUser);
}, []); // created once
useEffect(() => {
fetchUser();
}, [fetchUser]); // now stable
Case 4: State Update Without a Guard Condition
Dependency arrays don't protect you from yourself. If you unconditionally set state that's also listed as a dependency, you've built a perfect loop.
// โ Broken โ items is a dependency AND gets set unconditionally
useEffect(() => {
setItems([...items, newItem]); // sets items โ re-render โ effect runs again
}, [items]);
// โ
Fixed โ use the functional updater form and depend only on newItem
useEffect(() => {
if (newItem) {
setItems(prev => [...prev, newItem]);
}
}, [newItem]);
The functional form prev => [...prev, newItem] reads the current state internally โ no need to list items as a dependency at all.
Case 5: Data Fetching + State Update Loop
This one catches experienced developers too. Putting the state variable you're about to set into the dependency array is the trap:
// โ Broken โ user is a dependency, but setUser changes user on every fetch
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(data => {
setUser(data);
setLoading(false);
});
}, [userId, user]); // user here is the problem
// โ
Fixed โ fetch only when userId changes
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(data => {
setUser(data);
setLoading(false);
});
}, [userId]);
Verifying the Fix
- Open Chrome DevTools โ Console tab
- Hard-reload the page (Ctrl+Shift+R / Cmd+Shift+R)
- Confirm the
Maximum update depth exceededwarning is gone - Open React DevTools โ Components tab โ the component should stop flashing with constant re-renders
- Check the Network tab to confirm API calls are not firing in a loop (you should see one request, not 50+)
Quick Diagnostic Checklist
- Does the
useEffecthave a dependency array at all? - Is any dependency an object or array defined inside the component body?
- Is any dependency a function defined inside the component body?
- Is the state variable being set also listed as a dependency?
- Is the state update conditional or does it always fire?
The ESLint Rule That Catches This Early
eslint-plugin-react-hooks ships with Create React App and Next.js by default. If you're on a custom setup, add it manually:
npm install eslint-plugin-react-hooks --save-dev
// .eslintrc
{
"plugins": ["react-hooks"],
"rules": {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
}
}
The exhaustive-deps rule flags missing or incorrect dependencies at write-time, before anything reaches the browser. It's not perfect, but it catches the obvious cases โ including most of the patterns above.

