Fix: React useEffect runs infinitely (infinite loop / maximum update depth exceeded)
Quick Answer
How to fix useEffect infinite loops in React — covers missing dependency arrays, referential equality, useCallback, unconditional setState, data fetching cleanup, event listeners, useRef, previous value comparison, and the exhaustive-deps lint rule.
The Error
Your React component mounts and immediately starts running the same useEffect over and over. The browser tab freezes, the console fills with network requests or log statements, and eventually React throws one of these errors:
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.Uncaught Error: Too many re-renders. React limits the number of renders to prevent an infinite loop.
at renderWithHooks (react-dom.development.js)You might also see no error at all — just a component that keeps fetching data, logging to the console, or visibly flickering as it re-renders hundreds of times per second. In Next.js applications, this can crash the entire page and trigger a hydration mismatch on top of the loop if the server-rendered output diverges from what the client produces during the storm of re-renders.
This means a useEffect is triggering a state update on every run, and that state update causes a re-render, which causes the effect to fire again — an infinite cycle.
Why This Happens
useEffect runs after every render by default. React uses the dependency array (the second argument to useEffect) to decide whether the effect should re-run:
- No dependency array — the effect runs after every single render.
- Empty dependency array
[]— the effect runs once after the initial mount. - Array with values
[a, b]— the effect re-runs whenever any value in the array changes (compared withObject.is).
An infinite loop happens when the effect triggers a state change that causes a re-render, and after that re-render the dependency array comparison tells React the effect needs to run again. The most common reasons are:
- You forgot the dependency array entirely.
- A dependency is a new object, array, or function reference on every render (referential equality trap).
- The effect updates a piece of state that is also listed in its own dependency array.
- The effect calls
setStateunconditionally and the new state value is always different from the previous one.
Understanding these four root causes covers virtually every infinite loop scenario. The fixes below address each one with concrete code examples.
Fix 1: Add the Missing Dependency Array
The most basic mistake is omitting the dependency array altogether. Without it, useEffect runs after every render, and if it updates state, each update triggers another render.
Broken code:
function UserList() {
const [users, setUsers] = useState([]);
// No dependency array — runs after EVERY render
useEffect(() => {
fetch('/api/users')
.then(res => res.json())
.then(setUsers);
});
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}setUsers updates state, which triggers a re-render, which runs the effect again, which calls setUsers again — an infinite loop of network requests.
Fix — add an empty dependency array to run once on mount:
useEffect(() => {
fetch('/api/users')
.then(res => res.json())
.then(setUsers);
}, []); // Runs once after initial renderIf the effect should re-run when a specific value changes, add that value to the array:
useEffect(() => {
fetch(`/api/users?role=${role}`)
.then(res => res.json())
.then(setUsers);
}, [role]); // Re-runs only when role changesThis is the same root cause behind the Too many re-renders error — React detects the runaway cycle and kills it.
Fix 2: Object or Array in Dependencies (Referential Equality)
JavaScript compares objects and arrays by reference, not by content. If you create an object or array inside the component body and list it as a dependency, React sees a “new” value on every render because the reference is different each time.
Broken code:
function Dashboard({ userId }) {
const [data, setData] = useState(null);
// New object on every render — new reference each time
const params = { userId, includeStats: true };
useEffect(() => {
fetch('/api/dashboard', {
method: 'POST',
body: JSON.stringify(params),
})
.then(res => res.json())
.then(setData);
}, [params]); // params is a new object every render — infinite loop
}Even though params has the same content between renders, { userId: 1 } !== { userId: 1 } in JavaScript. The effect sees a “changed” dependency every time and re-runs.
Fix — move the object inside the effect:
useEffect(() => {
const params = { userId, includeStats: true };
fetch('/api/dashboard', {
method: 'POST',
body: JSON.stringify(params),
})
.then(res => res.json())
.then(setData);
}, [userId]); // Depend on the primitive value, not the objectOr stabilize the reference with useMemo:
const params = useMemo(
() => ({ userId, includeStats: true }),
[userId]
);
useEffect(() => {
fetch('/api/dashboard', {
method: 'POST',
body: JSON.stringify(params),
})
.then(res => res.json())
.then(setData);
}, [params]); // Stable reference now — only changes when userId changesThe same issue applies to arrays:
// Broken — new array every render
const filters = [status, category];
useEffect(() => {
loadItems(filters);
}, [filters]); // Infinite loop
// Fixed — depend on individual primitives
useEffect(() => {
loadItems([status, category]);
}, [status, category]);If you are destructuring the response and hitting Cannot read properties of undefined, see TypeError: Cannot read properties of undefined for how to safely handle missing data from your fetch calls.
Pro Tip: When you suspect an object or array is causing an infinite loop, add a
console.loginside the effect and inspect the value. If the logged content looks identical between renders but the effect keeps firing, you have a referential equality problem — the content hasn’t changed, but the reference has.
Fix 3: Function in Dependencies (useCallback)
Functions defined inside a component body are re-created on every render. If you pass one as a useEffect dependency, the effect re-runs every time because the function reference changes.
Broken code:
function SearchPage({ query }) {
const [results, setResults] = useState([]);
// New function reference every render
const search = (q) => {
fetch(`/api/search?q=${encodeURIComponent(q)}`)
.then(res => res.json())
.then(setResults);
};
useEffect(() => {
search(query);
}, [query, search]); // search changes every render — infinite loop
}Fix — wrap the function with useCallback:
const search = useCallback((q) => {
fetch(`/api/search?q=${encodeURIComponent(q)}`)
.then(res => res.json())
.then(setResults);
}, []); // setResults identity is stable
useEffect(() => {
search(query);
}, [query, search]); // search is now stableOr define the function inside the effect:
useEffect(() => {
const search = (q) => {
fetch(`/api/search?q=${encodeURIComponent(q)}`)
.then(res => res.json())
.then(setResults);
};
search(query);
}, [query]); // No external function dependency neededDefining the function inside the effect is often the simplest fix. Use useCallback when the function is also needed outside the effect (for example, as an event handler or passed to a child component). If TypeScript complains about the callback parameter types, check Type is not assignable for how to annotate callback signatures correctly.
Fix 4: setState in useEffect Without a Condition
If your effect calls setState unconditionally and the new value is always different from the current state, you get a loop — even with a dependency array.
Broken code:
function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
// Runs, sets state, triggers re-render, effect runs again
setSeconds(seconds + 1);
}, [seconds]); // seconds changes every time — infinite loop
}Every call to setSeconds changes seconds, which is in the dependency array, so the effect fires again immediately.
Fix — use setInterval or remove the state from dependencies:
function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setSeconds(prev => prev + 1); // Updater function — no dependency on seconds
}, 1000);
return () => clearInterval(id);
}, []); // Runs once, interval handles updates
return <p>{seconds}s</p>;
}The updater function prev => prev + 1 lets you update state based on the previous value without adding the state variable to the dependency array.
Another common pattern is updating state only when a condition is met:
useEffect(() => {
if (data && data.status !== currentStatus) {
setCurrentStatus(data.status);
}
}, [data, currentStatus]);Make sure the condition actually prevents the update from running on every cycle. If data.status truly equals currentStatus after the first update, the loop stops. But if the comparison is between objects or arrays, referential equality will bite you again.
Fix 5: Fetching Data Without Cleanup
When an effect fetches data, the component might unmount or the dependencies might change before the fetch completes. Without cleanup, the stale response calls setState on an unmounted component or sets outdated data, which can trigger cascading re-renders.
Broken code:
function UserProfile({ userId }) {
const [profile, setProfile] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setProfile(data));
}, [userId]);
}This does not loop by itself, but if the parent rapidly changes userId (for example, from a search-as-you-type input), multiple fetches fire concurrently. The responses arrive out of order and each one calls setProfile, causing thrashing. In React 18 Strict Mode, effects run twice on mount in development, which makes the problem more visible.
Fix — use an AbortController to cancel stale requests:
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(res => res.json())
.then(data => setProfile(data))
.catch(err => {
if (err.name !== 'AbortError') {
console.error(err);
}
});
return () => controller.abort(); // Cancel the previous request
}, [userId]);The cleanup function runs before the effect fires again with a new userId, aborting the in-flight request. This prevents race conditions and ensures only the latest response updates state. If you use async/await instead of .then(), use a boolean flag:
useEffect(() => {
let cancelled = false;
async function loadProfile() {
const res = await fetch(`/api/users/${userId}`);
const data = await res.json();
if (!cancelled) {
setProfile(data);
}
}
loadProfile();
return () => { cancelled = true; };
}, [userId]);Fix 6: Event Listeners Not Cleaned Up
Adding an event listener in useEffect without removing it in the cleanup function means multiple listeners accumulate on each re-render. Each listener calls setState, multiplying the number of state updates per event.
Broken code:
function ScrollTracker() {
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
// Listener added on every render — never removed
window.addEventListener('scroll', () => {
setScrollY(window.scrollY);
});
}); // No dependency array — runs every render
}After 50 renders, 50 scroll listeners are attached. Each scroll event triggers 50 setScrollY calls.
Fix — add a cleanup function and a dependency array:
useEffect(() => {
const handleScroll = () => {
setScrollY(window.scrollY);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []); // Runs once, cleans up on unmountThe cleanup function (the function returned from the effect callback) removes the listener before the effect runs again or when the component unmounts. Always extract the handler into a named function so you can pass the same reference to both addEventListener and removeEventListener.
This pattern applies to any subscription: WebSocket connections, IntersectionObserver, ResizeObserver, MutationObserver, or third-party event emitters. If you forget cleanup, listeners stack up and state updates multiply, eventually crashing the tab.
Fix 7: Use useRef Instead of State for Non-Render Values
Not every value needs to live in state. If a value is used for tracking, measurement, or internal logic but doesn’t need to trigger a re-render, put it in a useRef. Updating a ref does not cause a re-render, which breaks the infinite loop.
Broken code:
function ClickTracker() {
const [clickCount, setClickCount] = useState(0);
useEffect(() => {
const handler = () => {
setClickCount(prev => prev + 1); // Triggers re-render on every click
};
document.addEventListener('click', handler);
return () => document.removeEventListener('click', handler);
}, []);
// clickCount is only used for analytics, not displayed
useEffect(() => {
if (clickCount > 0) {
sendAnalytics({ clicks: clickCount }); // Fires on every click
}
}, [clickCount]);
}Each click updates state, which triggers a re-render, which runs the analytics effect. If sendAnalytics itself triggers any state updates (for example, setting a “last sent” timestamp), you can end up in a loop.
Fix — use useRef for values that don’t affect the UI:
function ClickTracker() {
const clickCount = useRef(0);
useEffect(() => {
const handler = () => {
clickCount.current += 1; // No re-render
};
document.addEventListener('click', handler);
return () => document.removeEventListener('click', handler);
}, []);
// Send analytics on unmount or at intervals, not on every click
useEffect(() => {
const id = setInterval(() => {
if (clickCount.current > 0) {
sendAnalytics({ clicks: clickCount.current });
clickCount.current = 0;
}
}, 5000);
return () => clearInterval(id);
}, []);
}Mutating clickCount.current does not trigger a re-render. The analytics are batched and sent every 5 seconds instead of on every click, eliminating the cascade of renders. This pattern is useful for timers, previous value tracking, DOM element references, and any value that is read but should not cause visual updates. If a hook is called conditionally somewhere else in the component, that can also disrupt the render cycle and compound the problem.
Fix 8: Comparing Previous Values to Avoid Unnecessary Updates
Sometimes you need to run an effect when a dependency changes, but only update state if the new value is actually different from what you already have. Without a comparison, the effect updates state on every run, which triggers another render.
Broken code:
function FormattedPrice({ price, currency }) {
const [display, setDisplay] = useState('');
useEffect(() => {
const formatted = new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
}).format(price);
setDisplay(formatted); // Always sets state, even if formatted === display
}, [price, currency]); // Runs whenever price or currency changes
return <span>{display}</span>;
}This particular example won’t loop because setDisplay with the same string value won’t trigger a re-render (React bails out when the new state is identical to the current state for primitive values). But if display were an object or the formatting produced a slightly different result each time (rounding, locale differences), it would loop.
Fix — compare with the previous value before updating:
function FormattedPrice({ price, currency }) {
const [display, setDisplay] = useState('');
const prevPrice = useRef(price);
const prevCurrency = useRef(currency);
useEffect(() => {
if (prevPrice.current !== price || prevCurrency.current !== currency) {
const formatted = new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
}).format(price);
setDisplay(formatted);
prevPrice.current = price;
prevCurrency.current = currency;
}
}, [price, currency]);
return <span>{display}</span>;
}You can extract this pattern into a custom usePrevious hook:
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current;
}
function FormattedPrice({ price, currency }) {
const prevPrice = usePrevious(price);
useEffect(() => {
if (price !== prevPrice) {
// Only update when price actually changed
}
}, [price, prevPrice]);
}For derived values like this, the cleanest approach is often to skip state entirely and compute the value inline:
function FormattedPrice({ price, currency }) {
const display = useMemo(
() => new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(price),
[price, currency]
);
return <span>{display}</span>;
}No state, no effect, no loop — just a memoized computation.
Fix 9: eslint-plugin-react-hooks and the exhaustive-deps Rule
The react-hooks/exhaustive-deps ESLint rule catches most of these problems before they reach the browser. It warns you when a useEffect dependency array is missing values that the effect uses, or when it contains values that will cause re-runs.
Install and configure the plugin:
npm install eslint-plugin-react-hooks --save-dev// .eslintrc.json
{
"plugins": ["react-hooks"],
"rules": {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
}
}If you use Create React App, Next.js, or Vite with the React plugin, this rule is already included. The rule will flag cases like these:
// Warning: React Hook useEffect has a missing dependency: 'userId'
useEffect(() => {
fetchUser(userId);
}, []); // <- Missing userId
// Warning: The 'options' object makes the dependencies change on every render
useEffect(() => {
loadData(options);
}, [options]); // <- options is a new object each renderDo not suppress the warning with // eslint-disable-next-line unless you fully understand why the dependency is intentionally excluded. Instead, fix the underlying issue:
- If the warning says you’re missing a dependency, add it — and then address the infinite loop by stabilizing the value with
useMemo,useCallback, or by moving the value inside the effect. - If the warning says a dependency changes on every render, wrap it with
useMemo(for objects/arrays) oruseCallback(for functions). - If you truly need the effect to run only once and the lint rule disagrees, restructure so the effect doesn’t reference the changing value. Use a ref to read the latest value without listing it as a dependency:
const userIdRef = useRef(userId);
userIdRef.current = userId;
useEffect(() => {
// Read from ref — not a dependency
doSomethingOnce(userIdRef.current);
}, []); // No warning, no loopThe exhaustive-deps rule is your first line of defense against infinite loops. Treat its warnings seriously — they almost always point to a real bug.
Still Not Working?
Check for Loops in Custom Hooks
If you’re using a custom hook that returns objects or arrays, the hook might be creating new references on every render. Trace into the hook’s implementation and check if it memoizes its return value.
// Buggy custom hook — returns a new object every render
function useAuth() {
const [user, setUser] = useState(null);
// New object reference every render
return { user, isLoggedIn: !!user };
}
// Fixed — memoize the return value
function useAuth() {
const [user, setUser] = useState(null);
return useMemo(() => ({ user, isLoggedIn: !!user }), [user]);
}Verify React Strict Mode Isn’t Masking the Real Issue
In development mode, React 18+ runs effects twice on mount (mount, unmount, mount) to help you find missing cleanup functions. This double-invocation is intentional and only happens in development. If you see effects running twice but not infinitely, it’s Strict Mode, not a bug. If the double-run causes an actual infinite loop (for example, because the cleanup is missing and two listeners are registered), fix the cleanup function.
Inspect Network Requests
Open the browser Network tab and watch for repeated requests. If the same endpoint is called dozens of times in rapid succession, the loop is in a useEffect that fetches data. Add a console.trace() inside the effect to see the full call stack and identify which render triggered it.
Watch for Cascading State Updates
Sometimes the loop involves multiple components. Component A’s effect updates state, which re-renders Component B, whose effect updates state that propagates back to Component A. Map out which state each effect reads and writes. If you find a circular dependency, break it by lifting the shared state into a common parent or using a state management library.
Look for Conflicting Dependencies in Multiple Effects
If a single component has multiple useEffect hooks, one effect’s state update might trigger another effect, which updates state that triggers the first effect again. Combine related effects or reorganize state so that one effect’s output isn’t another effect’s input.
// Potential cascade — effect 1 sets filteredItems, effect 2 depends on it
useEffect(() => {
setFilteredItems(items.filter(predicate));
}, [items, predicate]);
useEffect(() => {
setDisplayCount(filteredItems.length);
}, [filteredItems]);
// Fixed — combine into one effect or derive the values
const filteredItems = useMemo(() => items.filter(predicate), [items, predicate]);
const displayCount = filteredItems.length; // No state, no effectDerived state is the most reliable way to break infinite loops. If a value can be computed from props or existing state, compute it — don’t store it in state and don’t use an effect to sync it.
Related: Fix: Too many re-renders | Fix: TypeError: Cannot read properties of undefined | Fix: Hydration failed because the initial UI does not match | Fix: Type is not assignable | Fix: React Hook cannot be called
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Next.js Image Optimization Errors – Invalid src, Missing Loader, or Unoptimized
How to fix Next.js Image component errors including 'Invalid src prop', 'hostname not configured', missing loader, and optimization failures in production.
Fix: Hydration failed because the initial UI does not match what was rendered on the server (Next.js)
How to fix the Next.js hydration mismatch error. Covers invalid HTML nesting, browser extensions, Date/time differences, useEffect for client-only code, dynamic imports, suppressHydrationWarning, localStorage, third-party scripts, Math.random, auth state, and React portals.
Fix: Loading chunk failed / ChunkLoadError
How to fix 'Loading chunk failed', 'ChunkLoadError', and 'Failed to fetch dynamically imported module' in webpack, Next.js, React, and Vite. Covers stale deployments, CDN caching, publicPath misconfiguration, service worker cache, code splitting, dynamic import retry strategies, React.lazy error boundaries, and Next.js-specific solutions.
Fix: process.env.VARIABLE_NAME Is Undefined (Node.js, React, Next.js, Vite)
How to fix 'process.env.VARIABLE_NAME is undefined' and environment variables not loading from .env files in Node.js, React, Next.js, Vite, and Docker.