Escaping useEffect Infinite Loops
How I Encountered This Problem
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.
What Confused Me Initially?
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:
useEffectruns → API call- API response comes,
setUser(data)executes userstate changes- Since
useris in the dependency array,useEffectruns again - Back to step 1, infinite loop
"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.
The Aha Moment
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!
Deep Dive
The Real Meaning of Dependency Arrays
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.
The Trap with Objects/Arrays in Dependencies
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 component
const 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 in Dependencies
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.
Empty Array [] vs No Array
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)- No array: Runs every render (depends on everything)
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.
Don't Ignore ESLint Warnings
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.
How I Applied This to My Code
Real-time Search Feature
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.
Data Synchronization
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?"
Importance of Cleanup Functions
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.
One-Line Summary
useEffect's dependency array means "re-run when these values change." Put values you read in the array, exclude values you write to avoid infinite loops. Objects/arrays/functions are compared by reference, so memoize with useMemo/useCallback or put them inside the effect.