React useEffect Infinite Loop: How to Fix It Once and For All
Every React developer has been there. You add a simple useEffect to fetch some data, hit save, and suddenly your browser tab grinds to a halt. The console is flooding with logs, your CPU fan sounds like a jet engine, and React DevTools shows thousands of re-renders per second. If you are searching for “react useeffect infinite loop how to fix,” you are in the right place.
Let’s break down why this happens, how to diagnose the exact cause in your codebase, and how to prevent it from ever happening again.
Table of Contents
- What Causes a useEffect Infinite Loop?
- Root Cause Analysis: How React’s Dependency Array Works
- The 5 Most Common Causes (With Fixes)
- Missing or Incorrect Dependencies
- Object and Array References
- Updating State Inside useEffect
- Function References Changing Every Render
- Boolean or Computed Values as Dependencies
- Advanced Edge Cases
- Prevention Tips and Best Practices
- Key Takeaways
- FAQ
What Causes a useEffect Infinite Loop?
An infinite loop in useEffect happens when a side effect triggers a state update, and that state update triggers the effect again — endlessly. At its core, the problem is always the same: something in your dependency array is changing on every render.
React’s useEffect hook runs after every render where one of its dependencies has changed. If a dependency changes on every single render (because it is a new object reference, a new function, or a value derived from unstable state), the effect fires again. If that effect then updates state, React re-renders, the dependency changes again, and you are stuck in an infinite cycle.
The key insight is this: infinite loops are rarely caused by logic errors in your effect body. They are almost always caused by unstable references in the dependency array.
Root Cause Analysis: How React’s Dependency Array Works
Before we fix anything, let’s make sure we understand what React is actually doing.
When you write:
useEffect(() => {
fetchData();
}, [dependency1, dependency2]);
React compares each dependency between the current render and the previous render using Object.is(). For primitive values (strings, numbers, booleans), this works exactly like ===. For objects, arrays, and functions, it compares by reference, not by value.
This is the root of most infinite loop problems. Two objects with identical contents are still considered “different” by React if they occupy different memory locations.
// These are different references, even though contents are identical
const obj1 = { name: "Alice" };
const obj2 = { name: "Alice" };
Object.is(obj1, obj2); // false
So if your dependency array contains an object or function that is recreated on every render, React sees it as “changed” every single time.
The 5 Most Common Causes (With Fixes)
Cause #1: Missing Dependency Array
This is the most common mistake for beginners. If you omit the dependency array entirely, useEffect runs after every render.
// ❌ BAD: No dependency array = runs after every render
useEffect(() => {
setUser({ name: "Alice" });
}); // No array here
Here is what happens:
1. Component renders
2. useEffect runs
3. setUser updates state
4. Component re-renders
5. useEffect runs again
6. Infinite loop
The Fix:
// ✅ GOOD: Empty array = runs only once after mount
useEffect(() => {
setUser({ name: "Alice" });
}, []);
An empty dependency array tells React to run this effect only once, after the initial mount. If your effect does not depend on any props or state, this is the correct approach.
Cause #2: Object or Array Dependencies
This is the sneakiest cause. Your dependency array looks correct, but an object or array is being recreated every render.
// ❌ BAD: The object is recreated every render
function UserCard({ userId }) {
const [user, setUser] = useState(null);
const fetchOptions = {
method: "GET",
headers: { Authorization: `Bearer ${token}` },
};
useEffect(() => {
fetch(`/api/users/${userId}`, fetchOptions)
.then((res) => res.json())
.then(setUser);
}, [userId, fetchOptions]); // fetchOptions is a new object every render
return <div>{user?.name}</div>;
}
Every time the component renders, JavaScript creates a brand new fetchOptions object. React compares the new reference to the old one using Object.is(), sees they are different (even though the contents are identical), and runs the effect again.
The Fix — Option A: Move the object outside the effect
// ✅ Move static config outside the component
const FETCH_OPTIONS = {
method: "GET",
headers: { Authorization: `Bearer ${token}` },
};
function UserCard({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`, FETCH_OPTIONS)
.then((res) => res.json())
.then(setUser);
}, [userId]); // Only userId is a dependency now
return <div>{user?.name}</div>;
}
The Fix — Option B: Use useMemo for dynamic objects
// ✅ Memoize the object so it only changes when dependencies change
function UserCard({ userId, token }) {
const [user, setUser] = useState(null);
const fetchOptions = useMemo(
() => ({
method: "GET",
headers: { Authorization: `Bearer ${token}` },
}),
[token]
);
useEffect(() => {
fetch(`/api/users/${userId}`, fetchOptions)
.then((res) => res.json())
.then(setUser);
}, [userId, fetchOptions]);
return <div>{user?.name}</div>;
}
useMemo caches the object reference. It only creates a new object when token changes, which stabilizes the dependency for useEffect.
Cause #3: Updating State Inside useEffect Without Proper Guards
Sometimes the logic inside your effect itself causes a state update that triggers another run. This commonly happens with data fetching without proper guards.
// ❌ BAD: No guard against unnecessary state updates
function ProductList() {
const [products, setProducts] = useState([]);
useEffect(() => {
fetch("/api/products")
.then((res) => res.json())
.then((data) => {
setProducts(data); // Always creates a new array reference
});
}, [products]); // products changes after every fetch
return <div>{products.map((p) => p.name)}</div>;
}
Here, products is in the dependency array. After the fetch completes, setProducts(data) updates state, which changes products, which triggers the effect again.
The Fix:
// ✅ Remove the state you are updating from the dependency array
function ProductList() {
const [products, setProducts] = useState([]);
useEffect(() => {
let isMounted = true;
fetch("/api/products")
.then((res) => res.json())
.then((data) => {
if (isMounted) {
setProducts(data);
}
});
return () => {
isMounted = false; // Prevent state update on unmounted component
};
}, []); // Empty array: fetch only on mount
return <div>{products.map((p) => p.name)}</div>;
}
The isMounted pattern also prevents the classic React warning: Can't perform a React state update on an unmounted component.
Cause #4: Function Dependencies
Functions defined inside your component body are recreated on every render. If you pass such a function as a dependency, you get an infinite loop.
// ❌ BAD: fetchUser is a new function reference every render
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const fetchUser = (id) => {
fetch(`/api/users/${id}`)
.then((res) => res.json())
.then(setUser);
};
useEffect(() => {
fetchUser(userId);
}, [userId, fetchUser]); // fetchUser changes every render
return <div>{user?.name}</div>;
}
The Fix — Option A: Move the function inside the effect
// ✅ Move the function inside useEffect
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
const fetchUser = () => {
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then(setUser);
};
fetchUser();
}, [userId]); // Only userId is a dependency
return <div>{user?.name}</div>;
}
The Fix — Option B: Wrap the function in useCallback
// ✅ Memoize the function with useCallback
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const fetchUser = useCallback(
(id) => {
fetch(`/api/users/${id}`)
.then((res) => res.json())
.then(setUser);
},
[] // No dependencies = stable reference
);
useEffect(() => {
fetchUser(userId);
}, [userId, fetchUser]);
return <div>{user?.name}</div>;
}
Use useCallback when the function is needed outside the effect as well (for example, if it is passed to a child component as a prop).
Cause #5: Derived or Computed Values as Dependencies
Sometimes you compute a value inline that looks primitive but actually creates a new reference each time.
// ❌ BAD: filter creates a new array every render
function Dashboard({ items }) {
const [activeItems, setActiveItems] = useState([]);
const filtered = items.filter((item) => item.active);
useEffect(() => {
setActiveItems(filtered);
}, [filtered]); // filtered is a new array every render
return <ActiveItemList items={activeItems} />;
}
The Fix:
// ✅ Option A: Use useMemo
function Dashboard({ items }) {
const activeItems = useMemo(
() => items.filter((item) => item.active),
[items]
);
return <ActiveItemList items={activeItems} />;
}
// ✅ Option B: Skip state entirely and derive during render
function Dashboard({ items }) {
const activeItems = items.filter((item) => item.active);
return <ActiveItemList items={activeItems} />;
}
In many cases, you do not need useEffect at all. If you can compute a value during render, do it directly. The React team’s official guidance is: avoid syncing state with derived values using effects.
Advanced Edge Cases
Edge Case #1: Custom Hooks with Unstable Returns
If you are using a third-party custom hook, it might return unstable references. For example:
// ❌ A poorly written custom hook can cause loops
function useWindowSize() {
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
const handler = () => setSize({ width: window.innerWidth, height: window.innerHeight });
window.addEventListener("resize", handler);
return () => window.removeEventListener("resize", handler);
}, []);
return { size, setSize }; // New object every render if not memoized
}
// When consumed:
function MyComponent() {
const { size } = useWindowSize();
useEffect(() => {
console.log("Window changed", size);
}, [size]); // size is a new object reference if the hook returns one
return <div>{size.width} x {size.height}</div>;
}
The Fix:
If you cannot control the custom hook’s implementation, destructure the primitive values you need:
function MyComponent() {
const { size } = useWindowSize();
const { width, height } = size; // Destructure primitives
useEffect(() => {
console.log("Window changed", width, height);
}, [width, height]); // Primitives compared by value
return <div>{width} x {height}</div>;
}
Edge Case #2: Context Value Changes
If you consume a context whose provider creates a new value object on every render, any effect depending on that context value will loop.
// ❌ BAD: Context value is a new object every render
function AppProvider({ children }) {
const [user, setUser] = useState(null);
return (
<AppContext.Provider value={{ user, setUser, theme: "dark" }}>
{children}
</AppContext.Provider>
);
}
Every consumer of this context re-renders when the provider re-renders, and the value object is always a new reference.
The Fix:
// ✅ Memoize the context value
function AppProvider({ children }) {
const [user, setUser] = useState(null);
const value = useMemo(
() => ({ user, setUser, theme: "dark" }),
[user]
);
return (
<AppContext.Provider value={value}>
{children}
</AppContext.Provider>
);
}
Edge Case #3: Strict Mode Double Invocation
In React 18 and later (still relevant in 2026 with React 19), development mode with <StrictMode> intentionally double-invokes effects on mount to help you find bugs. This is not an infinite loop — it only happens once on mount, not continuously.
If you see your effect running twice on mount but not infinitely, this is expected behavior in development. It will not happen in production.
// This is normal in StrictMode, not a bug:
useEffect(() => {
console.log("This logs twice in dev, once in prod");
}, []);
Debugging Strategy: Step-by-Step
When you encounter an infinite loop, follow this systematic approach:
- Add logging to identify which effect is looping.
useEffect(() => {
console.log("Effect ran with deps:", { userId, fetchOptions });
}, [userId, fetchOptions]);
-
Check each dependency with
console.log. If a dependency logs a new reference every render, that is your culprit. -
Use the
useDeepCompareEffectpattern for quick diagnosis. Installuse-deep-compareor write a custom hook:
import { useRef } from "react";
import { isEqual } from "lodash";
function useDeepCompareMemoize(value) {
const ref = useRef();
if (!isEqual(value, ref.current)) {
ref.current = value;
}
return ref.current;
}
function useDeepCompareEffect(callback, dependencies) {
useEffect(callback, useDeepCompareMemoize(dependencies));
}
If the loop stops with deep compare, you know a reference equality issue is the cause.
- Disable effects one by one by commenting them out to isolate which specific effect is looping.
Prevention Tips and Best Practices
Here are the habits that will prevent infinite loops from ever appearing in your code:
1. Always Use the eslint-plugin-react-hooks exhaustive-deps Rule
This ESLint rule warns you when dependencies are missing or unnecessary. It catches most infinite loop causes at build time.
// .eslintrc.json
{
"plugins": ["react-hooks"],
"rules": {
"react-hooks/exhaustive-deps": "warn"
}
}
2. Prefer Primitive Dependencies
When possible, extract primitive values (strings, numbers, booleans) from objects and use those as dependencies instead of the whole object.
// ❌ Object dependency
const user = { id: 1, name: "Alice" };
useEffect(() => { /* ... */ }, [user]);
// ✅ Primitive dependency
const { id } = user;
useEffect(() => { /* ... */ }, [id]);
3. Ask: Do I Even Need useEffect?
Many infinite loops exist because developers use useEffect for derived state. Before adding an effect, ask yourself:
- Can I compute this value during render? If yes, do not use
useEffect. - Can I handle this in an event handler instead? If yes, do not use
useEffect. - Am I syncing state to state? If yes, you probably do not need
useEffect.
// ❌ Unnecessary effect
const [filterText, setFilterText] = useState("");
const [filteredItems, setFilteredItems] = useState(items);
useEffect(() => {
setFilteredItems(items.filter((item) =>
item.name.includes(filterText)
));
}, [items, filterText]);
// ✅ Just compute during render
const [filterText, setFilterText] = useState("");
const filteredItems = items.filter((item) =>
item.name.includes(filterText)
);
4. Use the React Compiler (React 19+)
If you are on React 19 in 2026, the React Compiler automatically memoizes values and functions. This eliminates most reference instability issues without manual useMemo or useCallback calls.
// With React Compiler enabled, this is automatically optimized
function UserCard({ userId, token }) {
const fetchOptions = {
method: "GET",
headers: { Authorization: `Bearer ${token}` },
};
// React Compiler memoizes fetchOptions automatically
useEffect(() => {
fetch(`/api/users/${userId}`, fetchOptions);
}, [userId, fetchOptions]); // This is now stable
}
Enable it in your build config:
// vite.config.js
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [
react({
babel: {
plugins: ["babel-plugin-react-compiler"],
},
}),
],
});
5. Test with React Profiler
Use the React DevTools Profiler to identify unnecessary re-renders before they become infinite loops in production.
“`javascript
// Wrap your app with Profiler in development
import { Profiler } from “react”;
function onRenderCallback(