![]()
Preventing Infinite Loops in API Calls with useEffect
A common React mistake is creating infinite loops when fetching data due to incorrect useEffect dependencies. This happens when the effect triggers state updates that then trigger the effect again.
The Problem (Infinite API Calls)
// ❌ Creates infinite loop
function ProductList() {
const [products, setProducts] = useState([]);
const [page, setPage] = useState(1);
useEffect(() => {
fetch(`/api/products?page=${page}`)
.then(res => res.json())
.then(data => {
setProducts(data.products);
setPage(prev => prev + 1); // ❌ Triggers re-run
});
}, [page]); // Dependency on page
return <div>{/* render products */}</div>;
}
Why this happens:
- Effect runs when
pagechanges - Effect updates
page - Triggers effect again
- Infinite loop continues
Correct Solutions
1. Separate Loading Logic (Recommended)
function ProductList() {
const [products, setProducts] = useState([]);
const [page, setPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
const loadProducts = async () => {
setIsLoading(true);
try {
const res = await fetch(`/api/products?page=${page}`);
const data = await res.json();
setProducts(prev => [...prev, ...data.products]);
} finally {
setIsLoading(false);
}
};
loadProducts();
}, [page]); // Only fetch when page changes intentionally
const loadNextPage = () => setPage(prev => prev + 1);
return (
<div>
{/* render products */}
<button onClick={loadNextPage} disabled={isLoading}>
Load More
</button>
</div>
);
}
2. Using Empty Dependency Array
function UserProfile() {
const [user, setUser] = useState(null);
useEffect(() => {
// ✅ Runs only once on mount
fetch('/api/user')
.then(res => res.json())
.then(setUser);
}, []); // Empty array = no dependencies
}
3. Proper Dependency Management
function SearchResults({ query }) {
const [results, setResults] = useState([]);
useEffect(() => {
if (query) { // Only fetch when query changes
fetch(`/api/search?q=${query}`)
.then(res => res.json())
.then(setResults);
}
}, [query]); // Correct dependency
}
Common Infinite Loop Scenarios
- State updates in effect:
useEffect(() => {
fetchData().then(data => setCount(data.count)); // ❌ If count is a dependency
}, [count]);
- Object/array dependencies:
useEffect(() => {
fetchData(options); // ❌ options object changes every render
}, [options]);
- Function dependencies:
useEffect(() => {
fetchData(onSuccess); // ❌ onSuccess function changes every render
}, [onSuccess]);
Best Practices
- Be intentional with dependencies – Only include what’s needed
- Memoize objects/functions when needed in dependencies:
const options = useMemo(() => ({ page, sort }), [page, sort]);
const fetch = useCallback(() => fetchData(page), [page]);
- Separate concerns – Don’t mix loading logic with state updates
- Use cleanup functions for pending requests:
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal });
return () => controller.abort();
}, [url]);
- Consider custom hooks for data fetching:
const { data, error } = useFetch(`/api/products?page=${page}`);
Advanced Solutions
1. Debouncing API Calls
function Search() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
useEffect(() => {
if (!query) return;
const timer = setTimeout(() => {
fetch(`/api/search?q=${query}`)
.then(res => res.json())
.then(setResults);
}, 500);
return () => clearTimeout(timer);
}, [query]); // Only runs after user stops typing
}
2. Pagination with Cursor
function ProductList() {
const [products, setProducts] = useState([]);
const [cursor, setCursor] = useState(null);
const [hasMore, setHasMore] = useState(true);
const loadMore = useCallback(async () => {
if (!hasMore) return;
const res = await fetch(`/api/products?cursor=${cursor}`);
const data = await res.json();
setProducts(prev => [...prev, ...data.items]);
setCursor(data.nextCursor);
setHasMore(data.hasMore);
}, [cursor, hasMore]);
useEffect(() => {
loadMore();
}, [loadMore]); // Stable function reference
}
3. Using SWR or React Query
import useSWR from 'swr';
function Profile() {
const { data, error } = useSWR('/api/user', fetcher);
// Handles caching, revalidation, and deduplication automatically
}
Remember: The key to avoiding infinite loops is understanding that:
useEffectruns after render when dependencies change- State updates trigger re-renders
- New object/function references change every render
Always audit your dependency arrays and consider whether each dependency truly needs to trigger a re-fetch. For complex data fetching needs, consider dedicated libraries like SWR, React Query, or RTK Query.
