I was building a dashboard that fetched user data on mount. Everything looked fine — the component rendered, the API call fired, data came back. Then I glanced at the Network tab. Hundreds of requests, hammering the server in an infinite loop. The page had frozen solid.
The culprit? A single object sitting inside the useEffect dependency array.
If you've hit a similar wall — a component re-rendering forever, an API getting spammed, or your browser tab running out of memory — this article will walk you through exactly why it happens and how to fix every common pattern. By the end, you'll know how to read the render cycle, spot the traps before they bite, and debug infinite loops when they do sneak through.
Why Does useEffect Cause Infinite Loops?
Before fixing anything, it helps to understand the mechanics. React's render cycle with useEffect follows a strict sequence:
- Component renders (JSX evaluated, DOM updated)
- React runs effects whose dependencies changed
- If an effect calls
setState, the component re-renders - Go back to step 1
When that cycle has no exit condition, you get an infinite loop.
The dependency array is React's mechanism for breaking this cycle. It tells React: "only re-run this effect when these specific values change." Get it wrong — miss a value, include one that changes on every render — and the exit condition disappears.
The three most common mistakes that break the cycle:
- Missing the dependency array entirely — the effect runs after every single render
- Putting objects or arrays in the dependency array — JavaScript compares them by reference, not value, so they look "different" every render even when the data is identical
- Calling
setStateinside a fetch effect without guarding it — the state update triggers a render, which re-runs the fetch, which updates state again
Let's walk through each one with real code.
What Happens When You Omit the Dependency Array?
This is the most common mistake, and the easiest to understand. When you omit the dependency array entirely, React treats the effect as "run after every render." If that effect also sets state, you've wired a self-sustaining render loop.
The broken version:
// ❌ No dependency array — runs after every render
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => setUser(data)); // This triggers a re-render...
// ...which runs the effect again, which fetches again
}); // <-- No dependency array here
return <div>{user?.name}</div>;
}
The fix:
// ✅ Dependency array with userId — only re-runs when userId changes
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => setUser(data));
}, [userId]); // <-- Only re-runs when userId changes
return <div>{user?.name}</div>;
}
A useful mental model: the dependency array is a list of "the things this effect cares about." If userId changes, you want a fresh fetch. If userId stays the same, you don't. Write that intention into the array explicitly.
If you genuinely want an effect that runs only once on mount, pass an empty array []. That explicitly signals "no dependencies — run once and stop."
// ✅ Run once on mount only
useEffect(() => {
// initialization logic
}, []);
Why Do Objects and Arrays in the Dependency Array Cause Loops?
This is the trap that got me on that dashboard. It's subtle because the code looks completely reasonable — you're putting the right thing in the dependency array. The problem is how JavaScript compares objects.
When React checks whether a dependency changed, it uses Object.is() — essentially ===. For primitive values like strings and numbers, this works perfectly: "hello" === "hello" is true. But for objects and arrays, === compares references, not contents.
const a = { id: 1 };
const b = { id: 1 };
console.log(a === b); // false — different references, same data
Every time your component renders, object and array literals create new references. Even if the data inside them is identical, React sees a "changed" dependency and re-runs the effect.
The broken version:
// ❌ Options object created on every render — always a new reference
function SearchResults({ query }: { query: string }) {
const [results, setResults] = useState([]);
const options = { query, limit: 10 }; // New object on every render
useEffect(() => {
fetchSearch(options).then(setResults);
}, [options]); // options is always "new" — infinite loop
return <ul>{results.map((r) => <li key={r.id}>{r.title}</li>)}</ul>;
}
Fix 1: Use the primitive values directly
// ✅ Use primitives, not the object wrapper
function SearchResults({ query }: { query: string }) {
const [results, setResults] = useState([]);
useEffect(() => {
fetchSearch({ query, limit: 10 }).then(setResults);
}, [query]); // query is a string — stable comparison works fine
return <ul>{results.map((r) => <li key={r.id}>{r.title}</li>)}</ul>;
}
Fix 2: useMemo for stable object references
When the object is genuinely complex and you can't decompose it, useMemo creates a stable reference that only changes when its own dependencies change:
// ✅ useMemo gives you a stable reference
function SearchResults({ query, userId }: { query: string; userId: string }) {
const [results, setResults] = useState([]);
const options = useMemo(
() => ({ query, userId, limit: 10 }),
[query, userId] // options only changes when query or userId changes
);
useEffect(() => {
fetchSearch(options).then(setResults);
}, [options]);
return <ul>{results.map((r) => <li key={r.id}>{r.title}</li>)}</ul>;
}
Fix 3: useCallback for function dependencies
Functions have the same reference problem. A function defined inside a component body is a new function on every render:
// ❌ New function reference on every render
function DataLoader({ id }: { id: string }) {
const [data, setData] = useState(null);
const loadData = async () => { // New function every render
const result = await fetchById(id);
setData(result);
};
useEffect(() => {
loadData();
}, [loadData]); // loadData is always "new" — infinite loop
}
// ✅ useCallback stabilizes the function reference
function DataLoader({ id }: { id: string }) {
const [data, setData] = useState(null);
const loadData = useCallback(async () => {
const result = await fetchById(id);
setData(result);
}, [id]); // loadData only changes when id changes
useEffect(() => {
loadData();
}, [loadData]);
}
How Do You Fetch Data in useEffect Without an Infinite Loop?
Fetching data is one of the most common things you'll do in a useEffect. It's also a minefield for infinite loops. Here's the full correct pattern, with a few important extras.
The broken version:
// ❌ Infinite loop — fetching updates state, state triggers re-render, re-render triggers fetch
function PostList() {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
setLoading(true); // setState triggers re-render
fetch("/api/posts")
.then((res) => res.json())
.then((data) => {
setPosts(data);
setLoading(false);
});
}, [loading]); // loading was added to silence a lint warning — now it's a loop
}
The correct pattern:
// ✅ Correct data fetching with cleanup
function PostList() {
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false; // Cleanup flag
async function loadPosts() {
try {
setLoading(true);
const res = await fetch("/api/posts");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (!cancelled) { // Only update state if still mounted
setPosts(data);
setError(null);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err : new Error("Unknown error"));
}
} finally {
if (!cancelled) setLoading(false);
}
}
loadPosts();
return () => {
cancelled = true; // Cancel on unmount or before next effect run
};
}, []); // Empty array — fetch once on mount
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>;
}
Two things to notice here beyond just avoiding the loop:
- The
cancelledflag prevents state updates after the component unmounts, which avoids the "Can't perform a React state update on an unmounted component" warning. loadingis not in the dependency array. That's intentional —loadingis internal state managed by the effect, not a trigger for re-running it.
For production code, consider libraries like SWR or React Query. They handle caching, deduplication, revalidation, and all the edge cases that manual fetch logic misses.
What Is a Stale Closure and How Do You Fix It?
Once you nail down dependency arrays, you'll eventually run into a related issue: stale closures. This is when your effect "captures" a value from render time, but that value has since changed.
// ❌ Stale closure — count is always 0 inside the interval
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log(count); // Always logs 0 — stale closure
setCount(count + 1); // Always sets to 0 + 1 = 1, never increments
}, 1000);
return () => clearInterval(id);
}, []); // Empty array means count is frozen at 0
return <div>{count}</div>;
}
The interval's closure captured count at render time (when it was 0) and never gets a fresh value. Two solutions:
Solution 1: Functional state updater
// ✅ Functional updater always has the latest state
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount((prev) => prev + 1); // prev is always current — no closure issue
}, 1000);
return () => clearInterval(id);
}, []); // Empty array is now safe
return <div>{count}</div>;
}
Solution 2: useRef for values that shouldn't trigger re-renders
// ✅ useRef stores a mutable value without causing re-renders
function Logger({ label }: { label: string }) {
const labelRef = useRef(label);
useEffect(() => {
labelRef.current = label; // Keep ref in sync
}, [label]);
useEffect(() => {
const id = setInterval(() => {
console.log(labelRef.current); // Always fresh — ref isn't frozen
}, 1000);
return () => clearInterval(id);
}, []); // label doesn't need to be here
return null;
}
How Do You Debug a useEffect Infinite Loop?
When a loop slips through and you can't immediately see why, here's how to track it down.
Step 1: console.count to measure render frequency
function SuspectComponent({ data }: { data: SomeObject }) {
console.count("SuspectComponent render"); // Counts each render
useEffect(() => {
console.count("useEffect fired");
// your effect logic
}, [data]);
return <div />;
}
If "SuspectComponent render" and "useEffect fired" are both counting up rapidly, you have a loop. If only "render" is counting, the problem is somewhere in the render cycle itself, not the effect.
Step 2: React DevTools Profiler
Open React DevTools → Profiler → click Record → let it run for a second → stop. Look for components with a high render count and check what caused each render (DevTools shows the "why" for each re-render — which prop or state changed).
Step 3: Log dependency values between renders
// Utility hook to track which dependency triggered the effect
function useWhyDidYouUpdate(name: string, deps: Record<string, unknown>) {
const prevDeps = useRef<Record<string, unknown>>({});
useEffect(() => {
const changed: string[] = [];
for (const key of Object.keys(deps)) {
if (prevDeps.current[key] !== deps[key]) {
changed.push(
`${key}: ${JSON.stringify(prevDeps.current[key])} → ${JSON.stringify(deps[key])}`
);
}
}
if (changed.length > 0) {
console.log(`[${name}] deps changed:`, changed);
}
prevDeps.current = deps;
});
}
// Usage
function MyComponent({ config }: { config: Config }) {
useWhyDidYouUpdate("MyComponent", { config });
useEffect(() => {
// ...
}, [config]);
}
This will tell you exactly which dependency changed between renders, and what it changed from and to. If you see config: [object] → [object] with the same-looking data, you've found a reference trap.
Step 4: Binary search the dependencies
If you have a complex effect with many dependencies and can't pinpoint the culprit, comment them out one by one:
useEffect(() => {
// ...
}, [
dep1,
// dep2, // comment these out one at a time
// dep3,
// dep4,
]);
When removing a dependency stops the loop, that's your culprit. Then fix it properly rather than just leaving it commented out.
Wrapping Up
useEffect infinite loops all trace back to the same root cause: React's dependency comparison mechanism and how JavaScript handles reference equality. Once you internalize those mechanics, the patterns become predictable.
Here's the quick reference:
| Pattern | Cause | Fix |
|---|---|---|
| No dependency array | Runs after every render | Add [] or list real deps |
| Object/array in deps | New reference every render | Decompose to primitives or use useMemo |
| Function in deps | New reference every render | Use useCallback |
| Fetch + setState loop | State triggers re-render triggers fetch | Cleanup flag + empty dep array |
| Stale closure | Captured value frozen at render time | Functional updater or useRef |
The debugging workflow that saves the most time:
- Add
console.countat the top of the component to confirm it's looping - Open React DevTools Profiler to see what's triggering re-renders
- Use the
useWhyDidYouUpdatehook to identify which specific dependency is changing - Apply the right fix (not just
eslint-disable)
The ESLint react-hooks/exhaustive-deps rule is genuinely your best first line of defense. Enable it if you haven't already, and treat its warnings as real bugs rather than noise.
If you want to go further, React Query and SWR handle the data-fetching patterns automatically — caching, deduplication, background revalidation — so you're not writing the same cleanup logic by hand in every component.
Once you've internalized these patterns, hooks stop feeling like a minefield and start feeling like a well-designed tool. The render cycle is predictable; it's just a matter of learning to read it.