Infinite loop in API calls due to incorrect useEffect dependencies

Loading

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:

  1. Effect runs when page changes
  2. Effect updates page
  3. Triggers effect again
  4. 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

  1. State updates in effect:
   useEffect(() => {
     fetchData().then(data => setCount(data.count)); // ❌ If count is a dependency
   }, [count]);
  1. Object/array dependencies:
   useEffect(() => {
     fetchData(options); // ❌ options object changes every render
   }, [options]); 
  1. Function dependencies:
   useEffect(() => {
     fetchData(onSuccess); // ❌ onSuccess function changes every render
   }, [onSuccess]);

Best Practices

  1. Be intentional with dependencies – Only include what’s needed
  2. Memoize objects/functions when needed in dependencies:
   const options = useMemo(() => ({ page, sort }), [page, sort]);
   const fetch = useCallback(() => fetchData(page), [page]);
  1. Separate concerns – Don’t mix loading logic with state updates
  2. Use cleanup functions for pending requests:
   useEffect(() => {
     const controller = new AbortController();
     fetch(url, { signal: controller.signal });
     return () => controller.abort();
   }, [url]);
  1. 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:

  1. useEffect runs after render when dependencies change
  2. State updates trigger re-renders
  3. 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.

Leave a Reply

Your email address will not be published. Required fields are marked *