
Escaping the useEffect Infinite Loop
My experience getting stuck in an infinite loop due to useEffect dependency arrays, and how I escaped.

My experience getting stuck in an infinite loop due to useEffect dependency arrays, and how I escaped.
Class is there, but style is missing? Debugging Tailwind CSS like a detective.

Think Android is easier than iOS? Meet Gradle Hell. Learn to fix minSdkVersion conflicts, Multidex limit errors, Namespace issues in Gradle 8.0, and master dependency analysis with `./gradlew dependencies`.

App crashed with TypeError? Learn why 'Null is not a subtype of String' happens and how to make your JSON parsing bulletproof with Zod/Freezed.

Obsessively wrapping everything in `useMemo`? It might be hurting your performance. Learn the hidden costs of memoization and when to actually use it.

I was building a feature to fetch user data for my service. Simple logic: call an API, get the data, store it in state. I used useEffect to fetch data when the component mounts.
But as soon as I ran the code, my browser froze and thousands of API requests started firing. Looking at the Network tab in DevTools, I saw the same request repeating like crazy. My Supabase free tier quota was gone in seconds.
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(data => setUser(data));
}, [user]); // 🔥 This was the problem
return <div>{user?.name}</div>;
}
At first, I panicked. "Why is this happening?" I clearly used useEffect, so why does it keep running? I quickly closed the browser tab and looked at the code again, but couldn't immediately see what was wrong.
What was even more terrifying was that I discovered this bug right before deploying to production. If actual users had experienced this... just thinking about it makes me shudder.
My misconception was: "Just put all related variables in the dependency array, right?"
The official docs said "put all values used inside the effect in the dependency array." So I thought since I'm using user, I should obviously put it in the dependency array.
But this was what caused the infinite loop:
useEffect runs → API callsetUser(data) executesuser state changesuser is in the dependency array, useEffect runs again"Then how am I supposed to use the dependency array?" I thought. When I put it in, I get an infinite loop. When I don't, ESLint warns me... it was so frustrating.
What confused me more was that sometimes leaving the dependency array empty ([]) worked fine, but sometimes it caused problems. "What's the rule here?" I wanted to scream.
I understood the infinite loop principle when I heard this analogy:
"useEffect is a 'change detector.' When values in the dependency array change, it thinks 'Oh, something changed? I should run again.' But if you change that value inside the effect? The detector goes 'Changed again? Run again!' infinitely."Ah! That's why I shouldn't put user in the dependency array. I'm changing user inside useEffect, so putting it as a dependency means it keeps triggering itself.
So what should go in the dependency array? The key insight I gained was:
"Put values you only 'read' in the dependency array, not values you 'write' (change) inside the effect."In my case, I only read userId, so it should be in the dependency array. But I write (change) user, so it shouldn't be there.
useEffect(() => {
fetchUser(userId).then(data => setUser(data));
}, [userId]); // ✅ Only userId in dependencies
Now the API is only called again when userId changes. Perfect!
The useEffect dependency array means "re-run this effect when these values change." React compares previous render values with current render values, and if any changed, it runs the effect.
useEffect(() => {
console.log('Effect running');
}, [a, b, c]);
// Runs if any of a, b, c change
The important part is what "changed" means. React uses Object.is() comparison. Primitive types (numbers, strings, booleans) are compared by value, but objects and arrays are compared by reference.
This causes another infinite loop:
function SearchResults() {
const [results, setResults] = useState([]);
const filters = { category: 'tech', minPrice: 100 }; // 🔥 New object every render!
useEffect(() => {
searchAPI(filters).then(data => setResults(data));
}, [filters]); // Infinite loop!
}
Why an infinite loop? filters is created as a new object every render. Even though the content is the same, the reference is different, so React thinks "it changed" and runs the effect again.
Three solutions:
Solution 1: Move object outside componentconst FILTERS = { category: 'tech', minPrice: 100 }; // Outside component
function SearchResults() {
const [results, setResults] = useState([]);
useEffect(() => {
searchAPI(FILTERS).then(data => setResults(data));
}, []); // FILTERS never changes, no dependency needed
}
Solution 2: Memoize with useMemo
function SearchResults() {
const [results, setResults] = useState([]);
const filters = useMemo(
() => ({ category: 'tech', minPrice: 100 }),
[] // No dependencies, created once
);
useEffect(() => {
searchAPI(filters).then(data => setResults(data));
}, [filters]); // ✅ filters doesn't change, runs once
}
Solution 3: Split dependencies into individual values
function SearchResults({ category, minPrice }) {
const [results, setResults] = useState([]);
useEffect(() => {
const filters = { category, minPrice };
searchAPI(filters).then(data => setResults(data));
}, [category, minPrice]); // ✅ Only primitives in dependencies
}
I usually prefer solution 3. It's clearest and least error-prone.
Functions are objects too, so same problem:
function DataFetcher() {
const [data, setData] = useState(null);
const fetchData = () => { // 🔥 New function every render!
return api.getData();
};
useEffect(() => {
fetchData().then(result => setData(result));
}, [fetchData]); // Infinite loop!
}
Solution is useCallback:
function DataFetcher() {
const [data, setData] = useState(null);
const fetchData = useCallback(() => {
return api.getData();
}, []); // No dependencies, created once
useEffect(() => {
fetchData().then(result => setData(result));
}, [fetchData]); // ✅ fetchData doesn't change, runs once
}
But a simpler way is to put the function inside the effect:
function DataFetcher() {
const [data, setData] = useState(null);
useEffect(() => {
const fetchData = () => api.getData(); // Defined inside effect
fetchData().then(result => setData(result));
}, []); // ✅ No dependencies
}
This is cleanest. If the function is only used inside the effect, no reason to extract it.
What if you don't use a dependency array at all?
// Pattern 1: Empty array
useEffect(() => {
console.log('Once on mount');
}, []);
// Pattern 2: No array
useEffect(() => {
console.log('Every render');
});
[]: Runs once on mount (no dependencies, never changes)I didn't know this difference at first and had performance issues by not using an array. The component called the API every re-render, making it super slow.
ESLint's exhaustive-deps rule is really useful. Ignoring this warning causes bugs later:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(data => setUser(data));
}, []); // ⚠️ ESLint warning: add userId to dependencies
}
This code seems to work at first. But when userId changes, it doesn't fetch new data. When users switch profiles, they keep seeing the previous user's info - a bug.
When ESLint warns, 99% of the time it's a real problem. Don't ignore it, fix it properly.
When building search for my service, I wrote this:
function SearchBox() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
useEffect(() => {
if (query.length > 0) {
searchAPI(query).then(data => setResults(data));
}
}, [query]); // Search whenever query changes
return (
<input
value={query}
onChange={e => setQuery(e.target.value)}
/>
);
}
This worked well. Every time the user types, query changes and search executes.
But there was a problem. If a user searches for "React" by typing "R", "Re", "Rea", "Reac", "React", the API is called 5 times. Too many.
I added debouncing:
function SearchBox() {
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
const [results, setResults] = useState([]);
// Update debouncedQuery 500ms after query changes
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedQuery(query);
}, 500);
return () => clearTimeout(timer); // cleanup
}, [query]);
// Search only when debouncedQuery changes
useEffect(() => {
if (debouncedQuery.length > 0) {
searchAPI(debouncedQuery).then(data => setResults(data));
}
}, [debouncedQuery]);
return (
<input
value={query}
onChange={e => setQuery(e.target.value)}
/>
);
}
Now search only executes 500ms after the user stops typing. API calls drastically reduced.
When synchronizing multiple states, I use useEffect:
function PriceCalculator() {
const [quantity, setQuantity] = useState(1);
const [price, setPrice] = useState(100);
const [total, setTotal] = useState(100);
useEffect(() => {
setTotal(quantity * price);
}, [quantity, price]); // Recalculate total when quantity or price changes
return (
<div>
<input value={quantity} onChange={e => setQuantity(+e.target.value)} />
<input value={price} onChange={e => setPrice(+e.target.value)} />
<p>Total: {total}</p>
</div>
);
}
This works, but actually total doesn't need to be state. It's a calculated value:
function PriceCalculator() {
const [quantity, setQuantity] = useState(1);
const [price, setPrice] = useState(100);
const total = quantity * price; // Just calculate it!
return (
<div>
<input value={quantity} onChange={e => setQuantity(+e.target.value)} />
<input value={price} onChange={e => setPrice(+e.target.value)} />
<p>Total: {total}</p>
</div>
);
}
Much simpler. Before using useEffect, ask "Do I really need an effect for this?"
Not canceling API calls can cause memory leaks:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
let cancelled = false;
fetchUser(userId).then(data => {
if (!cancelled) { // Only update if not cancelled
setUser(data);
}
});
return () => {
cancelled = true; // cleanup: set cancel flag
};
}, [userId]);
return <div>{user?.name}</div>;
}
If users quickly switch profiles, previous requests might still be in progress. With a cleanup function to ignore previous requests, you avoid "setState on unmounted component" warnings.