AdSense Leaderboard

Google AdSense Placeholder

HEADER SLOT

Infinite Loading with useEffect: Complete Guide (2026)

Last updated:
Infinite Loading with useEffect: Complete Guide (2026)

Master infinite scroll in React with useEffect and IntersectionObserver. Learn race condition prevention, memory optimization, and performance patterns with production code.

# Infinite Loading with useEffect: Complete Guide (2026)

Infinite scroll feels simple—fetch more data when user reaches the bottom. Then you implement it and discover race conditions, duplicate requests, memory leaks, and janky scrolling. Users scroll too fast, your app makes 10 simultaneous requests, and the whole thing crashes.

This guide shows you how to build production-ready infinite scroll using useEffect and IntersectionObserver. You'll learn to prevent race conditions, handle errors gracefully, optimize memory usage, and create smooth, performant infinite loading that works on slow connections and fast fingers.

# Table of Contents

  1. Why Infinite Loading?
  2. The Problem with Naive Implementation
  3. IntersectionObserver: The Right Approach
  4. Basic Implementation with useEffect
  5. Preventing Race Conditions
  6. Loading State Management
  7. Error Handling and Retry Logic
  8. Memory Optimization Techniques
  9. Scroll Position Restoration
  10. Complete Custom Hook: useInfiniteScroll
  11. Practical Example: Twitter-Style Feed
  12. FAQ

# Why Infinite Loading?

Traditional pagination breaks the flow. Users click "Next", wait for page reload, lose their scroll position, then hunt for where they were. Infinite scroll solves this by loading content seamlessly as users scroll.

When to use infinite scroll:

  • Social media feeds (Twitter, Instagram, TikTok)
  • Image galleries and photo streams
  • News feeds and article lists
  • Product catalogs in mobile apps
  • Chat message history
  • Search results (with caveats)

When NOT to use infinite scroll:

  • E-commerce with footer content (users can't reach footer)
  • Long-form content with specific destinations
  • Data tables where users need to jump to specific pages
  • Content where users need to track position

Comparison with traditional pagination:

Aspect Infinite Scroll Traditional Pagination
User Experience Seamless, addictive Deliberate, controlled
Mobile Excellent Requires precise taps
SEO Poor (content not crawlable) Good (each page indexed)
Performance Can degrade with many items Consistent
Accessibility Harder to implement Native keyboard nav
Memory Grows unbounded Fixed per page

# The Problem with Naive Implementation

Most developers start with something like this (don't use this):

# TypeScript Version

typescript
import { useState, useEffect } from 'react';

// ❌ WRONG: Multiple critical issues
function InfiniteList() {
  const [items, setItems] = useState<any[]>([]);
  const [page, setPage] = useState(1);

  useEffect(() => {
    // Issues:
    // 1. No loading state - multiple simultaneous requests
    // 2. No cleanup - race conditions
    // 3. No error handling
    // 4. Fires on EVERY scroll event (performance disaster)
    window.addEventListener('scroll', () => {
      if (window.innerHeight + window.scrollY >= document.body.offsetHeight) {
        fetch(`/api/items?page=${page}`)
          .then(res => res.json())
          .then(data => {
            setItems(prev => [...prev, ...data]);
            setPage(p => p + 1);
          });
      }
    });
  }, [page]);

  return (
    <ul>
      {items.map(item => (
        <li key={item.id}>{item.title}</li>
      ))}
    </ul>
  );
}

What's broken:

  1. Scroll event performance: Fires hundreds of times per second
  2. No cleanup: Creates new scroll listener on every render
  3. Race conditions: Fast scrolling triggers multiple requests
  4. No loading state: Can't prevent duplicate requests
  5. Memory leak: Scroll listeners never removed
  6. Infinite re-renders: useEffect runs on every page change

# IntersectionObserver: The Right Approach

IntersectionObserver is the modern solution for detecting when elements enter viewport. It's performant, battery-efficient, and designed exactly for this use case.

# How IntersectionObserver Works

typescript
// Create observer
const observer = new IntersectionObserver(
  (entries) => {
    // entries[0].isIntersecting === true when element is visible
    if (entries[0].isIntersecting) {
      console.log('Element entered viewport!');
    }
  },
  {
    root: null,           // null = viewport
    rootMargin: '0px',    // Trigger offset
    threshold: 0.1,       // 10% visible triggers callback
  }
);

// Watch an element
const element = document.querySelector('#sentinel');
observer.observe(element);

// Stop watching
observer.unobserve(element);
observer.disconnect(); // Stop all observations

Why IntersectionObserver over scroll events:

  • Fires only when visibility changes (not on every scroll)
  • Runs off main thread (doesn't block rendering)
  • Better battery life on mobile
  • Handles complex layouts automatically
  • Supports multiple threshold points

# Basic Implementation with useEffect

Let's build it correctly from the start:

# TypeScript Version

typescript
import { useState, useEffect, useRef, useCallback } from 'react';

interface Item {
  id: number;
  title: string;
}

function InfiniteList() {
  const [items, setItems] = useState<Item[]>([]);
  const [page, setPage] = useState(1);
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);

  // Ref to track the sentinel element
  const observerTarget = useRef<HTMLDivElement>(null);

  // Load more data
  const loadMore = useCallback(async () => {
    if (loading || !hasMore) return;

    setLoading(true);
    try {
      const response = await fetch(`/api/items?page=${page}`);
      const newItems: Item[] = await response.json();

      if (newItems.length === 0) {
        setHasMore(false);
      } else {
        setItems(prev => [...prev, ...newItems]);
        setPage(prev => prev + 1);
      }
    } catch (error) {
      console.error('Failed to load items:', error);
    } finally {
      setLoading(false);
    }
  }, [page, loading, hasMore]);

  // Set up IntersectionObserver
  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting && hasMore && !loading) {
          loadMore();
        }
      },
      { threshold: 1.0 } // Trigger when fully visible
    );

    const currentTarget = observerTarget.current;
    if (currentTarget) {
      observer.observe(currentTarget);
    }

    // Cleanup: disconnect observer
    return () => {
      if (currentTarget) {
        observer.unobserve(currentTarget);
      }
    };
  }, [loadMore, hasMore, loading]);

  return (
    <div>
      <ul>
        {items.map(item => (
          <li key={item.id}>{item.title}</li>
        ))}
      </ul>

      {/* Sentinel element - observed by IntersectionObserver */}
      <div ref={observerTarget} style={{ height: '20px' }} />

      {loading && <div>Loading more...</div>}
      {!hasMore && <div>No more items</div>}
    </div>
  );
}

# JavaScript Version

javascript
import { useState, useEffect, useRef, useCallback } from 'react';

function InfiniteList() {
  const [items, setItems] = useState([]);
  const [page, setPage] = useState(1);
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);

  const observerTarget = useRef(null);

  const loadMore = useCallback(async () => {
    if (loading || !hasMore) return;

    setLoading(true);
    try {
      const response = await fetch(`/api/items?page=${page}`);
      const newItems = await response.json();

      if (newItems.length === 0) {
        setHasMore(false);
      } else {
        setItems(prev => [...prev, ...newItems]);
        setPage(prev => prev + 1);
      }
    } catch (error) {
      console.error('Failed to load items:', error);
    } finally {
      setLoading(false);
    }
  }, [page, loading, hasMore]);

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting && hasMore && !loading) {
          loadMore();
        }
      },
      { threshold: 1.0 }
    );

    const currentTarget = observerTarget.current;
    if (currentTarget) {
      observer.observe(currentTarget);
    }

    return () => {
      if (currentTarget) {
        observer.unobserve(currentTarget);
      }
    };
  }, [loadMore, hasMore, loading]);

  return (
    <div>
      <ul>
        {items.map(item => (
          <li key={item.id}>{item.title}</li>
        ))}
      </ul>

      <div ref={observerTarget} style={{ height: '20px' }} />

      {loading && <div>Loading more...</div>}
      {!hasMore && <div>No more items</div>}
    </div>
  );
}

Key improvements:

  • loading state prevents duplicate requests
  • hasMore flag stops fetching when no items remain
  • Observer only triggers when sentinel visible AND not loading
  • Cleanup properly disconnects observer
  • Sentinel element is dedicated div at bottom

# Preventing Race Conditions

Fast scrolling can trigger multiple fetch requests before the first completes. This causes:

  • Duplicate items
  • Wrong page order
  • Wasted bandwidth
  • State corruption

# TypeScript Version

typescript
import { useState, useEffect, useRef, useCallback } from 'react';

interface Item {
  id: number;
  title: string;
}

function InfiniteListWithAbort() {
  const [items, setItems] = useState<Item[]>([]);
  const [page, setPage] = useState(1);
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);

  const observerTarget = useRef<HTMLDivElement>(null);
  const abortControllerRef = useRef<AbortController | null>(null);

  const loadMore = useCallback(async () => {
    if (loading || !hasMore) return;

    // Cancel previous request if still pending
    if (abortControllerRef.current) {
      abortControllerRef.current.abort();
    }

    // Create new abort controller for this request
    abortControllerRef.current = new AbortController();

    setLoading(true);
    try {
      const response = await fetch(`/api/items?page=${page}`, {
        signal: abortControllerRef.current.signal,
      });

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`);
      }

      const newItems: Item[] = await response.json();

      if (newItems.length === 0) {
        setHasMore(false);
      } else {
        setItems(prev => [...prev, ...newItems]);
        setPage(prev => prev + 1);
      }
    } catch (error) {
      // Don't show error if request was aborted
      if (error instanceof Error && error.name !== 'AbortError') {
        console.error('Failed to load items:', error);
      }
    } finally {
      setLoading(false);
    }
  }, [page, loading, hasMore]);

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting && hasMore && !loading) {
          loadMore();
        }
      },
      { threshold: 1.0 }
    );

    const currentTarget = observerTarget.current;
    if (currentTarget) {
      observer.observe(currentTarget);
    }

    return () => {
      if (currentTarget) {
        observer.unobserve(currentTarget);
      }
      // Abort any pending request when component unmounts
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }
    };
  }, [loadMore, hasMore, loading]);

  return (
    <div>
      <ul>
        {items.map(item => (
          <li key={item.id}>{item.title}</li>
        ))}
      </ul>

      <div ref={observerTarget} style={{ height: '20px' }} />

      {loading && <div>Loading more...</div>}
      {!hasMore && <div>No more items</div>}
    </div>
  );
}

# JavaScript Version

javascript
import { useState, useEffect, useRef, useCallback } from 'react';

function InfiniteListWithAbort() {
  const [items, setItems] = useState([]);
  const [page, setPage] = useState(1);
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);

  const observerTarget = useRef(null);
  const abortControllerRef = useRef(null);

  const loadMore = useCallback(async () => {
    if (loading || !hasMore) return;

    if (abortControllerRef.current) {
      abortControllerRef.current.abort();
    }

    abortControllerRef.current = new AbortController();

    setLoading(true);
    try {
      const response = await fetch(`/api/items?page=${page}`, {
        signal: abortControllerRef.current.signal,
      });

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`);
      }

      const newItems = await response.json();

      if (newItems.length === 0) {
        setHasMore(false);
      } else {
        setItems(prev => [...prev, ...newItems]);
        setPage(prev => prev + 1);
      }
    } catch (error) {
      if (error.name !== 'AbortError') {
        console.error('Failed to load items:', error);
      }
    } finally {
      setLoading(false);
    }
  }, [page, loading, hasMore]);

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting && hasMore && !loading) {
          loadMore();
        }
      },
      { threshold: 1.0 }
    );

    const currentTarget = observerTarget.current;
    if (currentTarget) {
      observer.observe(currentTarget);
    }

    return () => {
      if (currentTarget) {
        observer.unobserve(currentTarget);
      }
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }
    };
  }, [loadMore, hasMore, loading]);

  return (
    <div>
      <ul>
        {items.map(item => (
          <li key={item.id}>{item.title}</li>
        ))}
      </ul>

      <div ref={observerTarget} style={{ height: '20px' }} />

      {loading && <div>Loading more...</div>}
      {!hasMore && <div>No more items</div>}
    </div>
  );
}

Race condition prevention:

  1. Store AbortController in ref (persists across renders)
  2. Abort previous request before starting new one
  3. Pass signal to fetch
  4. Ignore AbortError (expected behavior)
  5. Cleanup aborts on unmount

# Loading State Management

Production apps need more than just "loading" boolean:

# TypeScript Version

typescript
import { useState, useRef, useCallback } from 'react';

type LoadingState = 'idle' | 'loading' | 'error' | 'success';

interface InfiniteState<T> {
  items: T[];
  page: number;
  hasMore: boolean;
  loadingState: LoadingState;
  error: string | null;
}

function useInfiniteLoadingState<T>() {
  const [state, setState] = useState<InfiniteState<T>>({
    items: [],
    page: 1,
    hasMore: true,
    loadingState: 'idle',
    error: null,
  });

  const abortControllerRef = useRef<AbortController | null>(null);

  const loadMore = useCallback(async (
    fetchFn: (page: number) => Promise<T[]>
  ) => {
    if (state.loadingState === 'loading' || !state.hasMore) return;

    if (abortControllerRef.current) {
      abortControllerRef.current.abort();
    }

    abortControllerRef.current = new AbortController();

    setState(prev => ({
      ...prev,
      loadingState: 'loading',
      error: null,
    }));

    try {
      const newItems = await fetchFn(state.page);

      setState(prev => ({
        ...prev,
        items: [...prev.items, ...newItems],
        page: prev.page + 1,
        hasMore: newItems.length > 0,
        loadingState: 'success',
      }));
    } catch (error) {
      if (error instanceof Error && error.name !== 'AbortError') {
        setState(prev => ({
          ...prev,
          loadingState: 'error',
          error: error.message,
        }));
      }
    }
  }, [state.page, state.hasMore, state.loadingState]);

  const reset = useCallback(() => {
    if (abortControllerRef.current) {
      abortControllerRef.current.abort();
    }
    setState({
      items: [],
      page: 1,
      hasMore: true,
      loadingState: 'idle',
      error: null,
    });
  }, []);

  return {
    ...state,
    loadMore,
    reset,
  };
}

# JavaScript Version

javascript
import { useState, useRef, useCallback } from 'react';

function useInfiniteLoadingState() {
  const [state, setState] = useState({
    items: [],
    page: 1,
    hasMore: true,
    loadingState: 'idle',
    error: null,
  });

  const abortControllerRef = useRef(null);

  const loadMore = useCallback(async (fetchFn) => {
    if (state.loadingState === 'loading' || !state.hasMore) return;

    if (abortControllerRef.current) {
      abortControllerRef.current.abort();
    }

    abortControllerRef.current = new AbortController();

    setState(prev => ({
      ...prev,
      loadingState: 'loading',
      error: null,
    }));

    try {
      const newItems = await fetchFn(state.page);

      setState(prev => ({
        ...prev,
        items: [...prev.items, ...newItems],
        page: prev.page + 1,
        hasMore: newItems.length > 0,
        loadingState: 'success',
      }));
    } catch (error) {
      if (error.name !== 'AbortError') {
        setState(prev => ({
          ...prev,
          loadingState: 'error',
          error: error.message,
        }));
      }
    }
  }, [state.page, state.hasMore, state.loadingState]);

  const reset = useCallback(() => {
    if (abortControllerRef.current) {
      abortControllerRef.current.abort();
    }
    setState({
      items: [],
      page: 1,
      hasMore: true,
      loadingState: 'idle',
      error: null,
    });
  }, []);

  return {
    ...state,
    loadMore,
    reset,
  };
}

Benefits of state machine approach:

  • Impossible states prevented (can't be loading AND error)
  • Clear state transitions
  • Easy to extend (add 'retrying' state)
  • TypeScript discriminated unions for type safety

# Error Handling and Retry Logic

Network failures happen. Production apps need retry capability:

# TypeScript Version

typescript
import { useState, useRef, useCallback } from 'react';

interface UseInfiniteScrollOptions {
  maxRetries?: number;
  retryDelay?: number;
}

function useInfiniteScrollWithRetry<T>(
  fetchFn: (page: number, signal: AbortSignal) => Promise<T[]>,
  options: UseInfiniteScrollOptions = {}
) {
  const { maxRetries = 3, retryDelay = 1000 } = options;

  const [items, setItems] = useState<T[]>([]);
  const [page, setPage] = useState(1);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [hasMore, setHasMore] = useState(true);
  const [retryCount, setRetryCount] = useState(0);

  const abortControllerRef = useRef<AbortController | null>(null);

  const loadMore = useCallback(async () => {
    if (loading || !hasMore) return;

    if (abortControllerRef.current) {
      abortControllerRef.current.abort();
    }

    abortControllerRef.current = new AbortController();
    setLoading(true);
    setError(null);

    let attempt = 0;
    while (attempt <= maxRetries) {
      try {
        const newItems = await fetchFn(
          page,
          abortControllerRef.current.signal
        );

        if (newItems.length === 0) {
          setHasMore(false);
        } else {
          setItems(prev => [...prev, ...newItems]);
          setPage(prev => prev + 1);
        }

        setRetryCount(0); // Reset retry count on success
        setLoading(false);
        return; // Success, exit
      } catch (err) {
        if (err instanceof Error && err.name === 'AbortError') {
          setLoading(false);
          return; // Request cancelled, exit quietly
        }

        attempt++;
        
        if (attempt > maxRetries) {
          // All retries exhausted
          setError(
            err instanceof Error 
              ? err.message 
              : 'Failed to load items'
          );
          setRetryCount(attempt);
          setLoading(false);
          return;
        }

        // Wait before retry (exponential backoff)
        const delay = retryDelay * Math.pow(2, attempt - 1);
        await new Promise(resolve => setTimeout(resolve, delay));
      }
    }
  }, [page, loading, hasMore, fetchFn, maxRetries, retryDelay]);

  const retry = useCallback(() => {
    setError(null);
    setRetryCount(0);
    loadMore();
  }, [loadMore]);

  const reset = useCallback(() => {
    if (abortControllerRef.current) {
      abortControllerRef.current.abort();
    }
    setItems([]);
    setPage(1);
    setHasMore(true);
    setLoading(false);
    setError(null);
    setRetryCount(0);
  }, []);

  return {
    items,
    loading,
    error,
    hasMore,
    retryCount,
    loadMore,
    retry,
    reset,
  };
}

# JavaScript Version

javascript
import { useState, useRef, useCallback } from 'react';

function useInfiniteScrollWithRetry(
  fetchFn,
  options = {}
) {
  const { maxRetries = 3, retryDelay = 1000 } = options;

  const [items, setItems] = useState([]);
  const [page, setPage] = useState(1);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const [hasMore, setHasMore] = useState(true);
  const [retryCount, setRetryCount] = useState(0);

  const abortControllerRef = useRef(null);

  const loadMore = useCallback(async () => {
    if (loading || !hasMore) return;

    if (abortControllerRef.current) {
      abortControllerRef.current.abort();
    }

    abortControllerRef.current = new AbortController();
    setLoading(true);
    setError(null);

    let attempt = 0;
    while (attempt <= maxRetries) {
      try {
        const newItems = await fetchFn(
          page,
          abortControllerRef.current.signal
        );

        if (newItems.length === 0) {
          setHasMore(false);
        } else {
          setItems(prev => [...prev, ...newItems]);
          setPage(prev => prev + 1);
        }

        setRetryCount(0);
        setLoading(false);
        return;
      } catch (err) {
        if (err.name === 'AbortError') {
          setLoading(false);
          return;
        }

        attempt++;
        
        if (attempt > maxRetries) {
          setError(err.message || 'Failed to load items');
          setRetryCount(attempt);
          setLoading(false);
          return;
        }

        const delay = retryDelay * Math.pow(2, attempt - 1);
        await new Promise(resolve => setTimeout(resolve, delay));
      }
    }
  }, [page, loading, hasMore, fetchFn, maxRetries, retryDelay]);

  const retry = useCallback(() => {
    setError(null);
    setRetryCount(0);
    loadMore();
  }, [loadMore]);

  const reset = useCallback(() => {
    if (abortControllerRef.current) {
      abortControllerRef.current.abort();
    }
    setItems([]);
    setPage(1);
    setHasMore(true);
    setLoading(false);
    setError(null);
    setRetryCount(0);
  }, []);

  return {
    items,
    loading,
    error,
    hasMore,
    retryCount,
    loadMore,
    retry,
    reset,
  };
}

Retry strategy:

  • Exponential backoff: 1s, 2s, 4s delays
  • Max 3 retries by default (configurable)
  • Manual retry button for user control
  • Abort requests on retry/reset
  • Clear error messages

# Memory Optimization Techniques

Infinite scroll can accumulate thousands of DOM nodes, causing performance degradation:

# TypeScript Version

typescript
import { useState, useEffect, useRef, useCallback } from 'react';

interface VirtualizedInfiniteProps<T> {
  fetchPage: (page: number) => Promise<T[]>;
  itemHeight: number;
  maxItemsInMemory?: number;
}

function useVirtualizedInfinite<T>({
  fetchPage,
  itemHeight,
  maxItemsInMemory = 200,
}: VirtualizedInfiniteProps<T>) {
  const [allItems, setAllItems] = useState<T[]>([]);
  const [page, setPage] = useState(1);
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);

  // Only render items near viewport
  const [visibleRange, setVisibleRange] = useState({ start: 0, end: 50 });
  const containerRef = useRef<HTMLDivElement>(null);

  // Update visible range based on scroll
  useEffect(() => {
    const handleScroll = () => {
      if (!containerRef.current) return;

      const scrollTop = containerRef.current.scrollTop;
      const visibleStart = Math.floor(scrollTop / itemHeight);
      const visibleEnd = visibleStart + 50; // Show 50 items

      setVisibleRange({ start: visibleStart, end: visibleEnd });
    };

    const container = containerRef.current;
    if (container) {
      container.addEventListener('scroll', handleScroll);
      return () => container.removeEventListener('scroll', handleScroll);
    }
  }, [itemHeight]);

  // Prune old items if exceeding limit
  useEffect(() => {
    if (allItems.length > maxItemsInMemory) {
      setAllItems(prev => prev.slice(-maxItemsInMemory));
    }
  }, [allItems.length, maxItemsInMemory]);

  const loadMore = useCallback(async () => {
    if (loading || !hasMore) return;

    setLoading(true);
    try {
      const newItems = await fetchPage(page);

      if (newItems.length === 0) {
        setHasMore(false);
      } else {
        setAllItems(prev => [...prev, ...newItems]);
        setPage(prev => prev + 1);
      }
    } catch (error) {
      console.error('Load failed:', error);
    } finally {
      setLoading(false);
    }
  }, [page, loading, hasMore, fetchPage]);

  // Visible items slice
  const visibleItems = allItems.slice(
    visibleRange.start,
    visibleRange.end
  );

  return {
    allItems,
    visibleItems,
    visibleRange,
    loading,
    hasMore,
    loadMore,
    containerRef,
    totalHeight: allItems.length * itemHeight,
  };
}

# JavaScript Version

javascript
import { useState, useEffect, useRef, useCallback } from 'react';

function useVirtualizedInfinite({
  fetchPage,
  itemHeight,
  maxItemsInMemory = 200,
}) {
  const [allItems, setAllItems] = useState([]);
  const [page, setPage] = useState(1);
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);

  const [visibleRange, setVisibleRange] = useState({ start: 0, end: 50 });
  const containerRef = useRef(null);

  useEffect(() => {
    const handleScroll = () => {
      if (!containerRef.current) return;

      const scrollTop = containerRef.current.scrollTop;
      const visibleStart = Math.floor(scrollTop / itemHeight);
      const visibleEnd = visibleStart + 50;

      setVisibleRange({ start: visibleStart, end: visibleEnd });
    };

    const container = containerRef.current;
    if (container) {
      container.addEventListener('scroll', handleScroll);
      return () => container.removeEventListener('scroll', handleScroll);
    }
  }, [itemHeight]);

  useEffect(() => {
    if (allItems.length > maxItemsInMemory) {
      setAllItems(prev => prev.slice(-maxItemsInMemory));
    }
  }, [allItems.length, maxItemsInMemory]);

  const loadMore = useCallback(async () => {
    if (loading || !hasMore) return;

    setLoading(true);
    try {
      const newItems = await fetchPage(page);

      if (newItems.length === 0) {
        setHasMore(false);
      } else {
        setAllItems(prev => [...prev, ...newItems]);
        setPage(prev => prev + 1);
      }
    } catch (error) {
      console.error('Load failed:', error);
    } finally {
      setLoading(false);
    }
  }, [page, loading, hasMore, fetchPage]);

  const visibleItems = allItems.slice(
    visibleRange.start,
    visibleRange.end
  );

  return {
    allItems,
    visibleItems,
    visibleRange,
    loading,
    hasMore,
    loadMore,
    containerRef,
    totalHeight: allItems.length * itemHeight,
  };
}

Memory optimization strategies:

  1. Virtualization: Only render visible items
  2. Pruning: Remove old items beyond limit
  3. Fixed heights: Enables precise scroll calculations
  4. Throttled scroll: Update visible range sparingly

For simple implementations:

typescript
// Basic pruning without virtualization
useEffect(() => {
  if (items.length > 500) {
    setItems(prev => prev.slice(-300)); // Keep last 300
  }
}, [items.length]);

# Scroll Position Restoration

When navigating away and back, restore scroll position:

# TypeScript Version

typescript
import { useEffect, useRef } from 'react';

function useScrollRestoration(key: string) {
  const scrollPositions = useRef<Map<string, number>>(new Map());

  // Save scroll position before navigation
  useEffect(() => {
    const handleBeforeUnload = () => {
      const scrollY = window.scrollY;
      scrollPositions.current.set(key, scrollY);
      sessionStorage.setItem(
        'scrollPositions',
        JSON.stringify(Array.from(scrollPositions.current.entries()))
      );
    };

    window.addEventListener('beforeunload', handleBeforeUnload);
    
    return () => {
      window.removeEventListener('beforeunload', handleBeforeUnload);
      handleBeforeUnload(); // Save on unmount too
    };
  }, [key]);

  // Restore scroll position on mount
  useEffect(() => {
    const saved = sessionStorage.getItem('scrollPositions');
    if (saved) {
      const entries = JSON.parse(saved);
      scrollPositions.current = new Map(entries);
      
      const position = scrollPositions.current.get(key);
      if (position !== undefined) {
        window.scrollTo(0, position);
      }
    }
  }, [key]);
}

// Usage
function InfiniteListWithRestoration() {
  useScrollRestoration('infinite-list');
  
  // Rest of infinite scroll implementation...
}

# JavaScript Version

javascript
import { useEffect, useRef } from 'react';

function useScrollRestoration(key) {
  const scrollPositions = useRef(new Map());

  useEffect(() => {
    const handleBeforeUnload = () => {
      const scrollY = window.scrollY;
      scrollPositions.current.set(key, scrollY);
      sessionStorage.setItem(
        'scrollPositions',
        JSON.stringify(Array.from(scrollPositions.current.entries()))
      );
    };

    window.addEventListener('beforeunload', handleBeforeUnload);
    
    return () => {
      window.removeEventListener('beforeunload', handleBeforeUnload);
      handleBeforeUnload();
    };
  }, [key]);

  useEffect(() => {
    const saved = sessionStorage.getItem('scrollPositions');
    if (saved) {
      const entries = JSON.parse(saved);
      scrollPositions.current = new Map(entries);
      
      const position = scrollPositions.current.get(key);
      if (position !== undefined) {
        window.scrollTo(0, position);
      }
    }
  }, [key]);
}

# Complete Custom Hook: useInfiniteScroll

Production-ready hook combining all patterns:

# TypeScript Version

typescript
import { useState, useEffect, useRef, useCallback } from 'react';

interface UseInfiniteScrollOptions {
  threshold?: number;
  rootMargin?: string;
  maxRetries?: number;
  retryDelay?: number;
}

interface UseInfiniteScrollReturn<T> {
  items: T[];
  loading: boolean;
  error: string | null;
  hasMore: boolean;
  sentinelRef: (node: HTMLElement | null) => void;
  retry: () => void;
  reset: () => void;
}

function useInfiniteScroll<T>(
  fetchPage: (page: number, signal: AbortSignal) => Promise<T[]>,
  options: UseInfiniteScrollOptions = {}
): UseInfiniteScrollReturn<T> {
  const {
    threshold = 1.0,
    rootMargin = '0px',
    maxRetries = 3,
    retryDelay = 1000,
  } = options;

  const [items, setItems] = useState<T[]>([]);
  const [page, setPage] = useState(1);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [hasMore, setHasMore] = useState(true);

  const observerRef = useRef<IntersectionObserver | null>(null);
  const abortControllerRef = useRef<AbortController | null>(null);
  const sentinelNodeRef = useRef<HTMLElement | null>(null);

  const loadMore = useCallback(async () => {
    if (loading || !hasMore) return;

    if (abortControllerRef.current) {
      abortControllerRef.current.abort();
    }

    abortControllerRef.current = new AbortController();
    setLoading(true);
    setError(null);

    let attempt = 0;
    while (attempt <= maxRetries) {
      try {
        const newItems = await fetchPage(
          page,
          abortControllerRef.current.signal
        );

        if (newItems.length === 0) {
          setHasMore(false);
        } else {
          setItems(prev => [...prev, ...newItems]);
          setPage(prev => prev + 1);
        }

        setLoading(false);
        return;
      } catch (err) {
        if (err instanceof Error && err.name === 'AbortError') {
          setLoading(false);
          return;
        }

        attempt++;

        if (attempt > maxRetries) {
          setError(
            err instanceof Error ? err.message : 'Failed to load items'
          );
          setLoading(false);
          return;
        }

        const delay = retryDelay * Math.pow(2, attempt - 1);
        await new Promise(resolve => setTimeout(resolve, delay));
      }
    }
  }, [page, loading, hasMore, fetchPage, maxRetries, retryDelay]);

  const retry = useCallback(() => {
    setError(null);
    loadMore();
  }, [loadMore]);

  const reset = useCallback(() => {
    if (abortControllerRef.current) {
      abortControllerRef.current.abort();
    }
    setItems([]);
    setPage(1);
    setHasMore(true);
    setLoading(false);
    setError(null);
  }, []);

  // Callback ref for sentinel element
  const sentinelRef = useCallback((node: HTMLElement | null) => {
    // Disconnect previous observer
    if (observerRef.current) {
      observerRef.current.disconnect();
    }

    // Create new observer
    observerRef.current = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting && hasMore && !loading) {
          loadMore();
        }
      },
      { threshold, rootMargin }
    );

    // Observe new node
    if (node) {
      sentinelNodeRef.current = node;
      observerRef.current.observe(node);
    }
  }, [hasMore, loading, loadMore, threshold, rootMargin]);

  // Cleanup on unmount
  useEffect(() => {
    return () => {
      if (observerRef.current) {
        observerRef.current.disconnect();
      }
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }
    };
  }, []);

  return {
    items,
    loading,
    error,
    hasMore,
    sentinelRef,
    retry,
    reset,
  };
}

export default useInfiniteScroll;

# JavaScript Version

javascript
import { useState, useEffect, useRef, useCallback } from 'react';

function useInfiniteScroll(fetchPage, options = {}) {
  const {
    threshold = 1.0,
    rootMargin = '0px',
    maxRetries = 3,
    retryDelay = 1000,
  } = options;

  const [items, setItems] = useState([]);
  const [page, setPage] = useState(1);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const [hasMore, setHasMore] = useState(true);

  const observerRef = useRef(null);
  const abortControllerRef = useRef(null);
  const sentinelNodeRef = useRef(null);

  const loadMore = useCallback(async () => {
    if (loading || !hasMore) return;

    if (abortControllerRef.current) {
      abortControllerRef.current.abort();
    }

    abortControllerRef.current = new AbortController();
    setLoading(true);
    setError(null);

    let attempt = 0;
    while (attempt <= maxRetries) {
      try {
        const newItems = await fetchPage(
          page,
          abortControllerRef.current.signal
        );

        if (newItems.length === 0) {
          setHasMore(false);
        } else {
          setItems(prev => [...prev, ...newItems]);
          setPage(prev => prev + 1);
        }

        setLoading(false);
        return;
      } catch (err) {
        if (err.name === 'AbortError') {
          setLoading(false);
          return;
        }

        attempt++;

        if (attempt > maxRetries) {
          setError(err.message || 'Failed to load items');
          setLoading(false);
          return;
        }

        const delay = retryDelay * Math.pow(2, attempt - 1);
        await new Promise(resolve => setTimeout(resolve, delay));
      }
    }
  }, [page, loading, hasMore, fetchPage, maxRetries, retryDelay]);

  const retry = useCallback(() => {
    setError(null);
    loadMore();
  }, [loadMore]);

  const reset = useCallback(() => {
    if (abortControllerRef.current) {
      abortControllerRef.current.abort();
    }
    setItems([]);
    setPage(1);
    setHasMore(true);
    setLoading(false);
    setError(null);
  }, []);

  const sentinelRef = useCallback((node) => {
    if (observerRef.current) {
      observerRef.current.disconnect();
    }

    observerRef.current = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting && hasMore && !loading) {
          loadMore();
        }
      },
      { threshold, rootMargin }
    );

    if (node) {
      sentinelNodeRef.current = node;
      observerRef.current.observe(node);
    }
  }, [hasMore, loading, loadMore, threshold, rootMargin]);

  useEffect(() => {
    return () => {
      if (observerRef.current) {
        observerRef.current.disconnect();
      }
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }
    };
  }, []);

  return {
    items,
    loading,
    error,
    hasMore,
    sentinelRef,
    retry,
    reset,
  };
}

export default useInfiniteScroll;

# Practical Example: Twitter-Style Feed

Real-world implementation with all features:

# TypeScript Version

typescript
import useInfiniteScroll from './hooks/useInfiniteScroll';

interface Tweet {
  id: string;
  author: string;
  content: string;
  timestamp: string;
  likes: number;
}

async function fetchTweets(
  page: number,
  signal: AbortSignal
): Promise<Tweet[]> {
  const response = await fetch(
    `https://api.example.com/tweets?page=${page}&limit=20`,
    { signal }
  );

  if (!response.ok) {
    throw new Error(`Failed to fetch tweets: ${response.statusText}`);
  }

  const data = await response.json();
  return data.tweets;
}

function TwitterFeed() {
  const {
    items: tweets,
    loading,
    error,
    hasMore,
    sentinelRef,
    retry,
    reset,
  } = useInfiniteScroll<Tweet>(fetchTweets, {
    threshold: 0.5, // Trigger when sentinel is 50% visible
    rootMargin: '100px', // Start loading 100px before reaching bottom
  });

  return (
    <div style={{ maxWidth: '600px', margin: '0 auto', padding: '20px' }}>
      <div style={{ 
        display: 'flex', 
        justifyContent: 'space-between',
        alignItems: 'center',
        marginBottom: '20px',
      }}>
        <h1>Twitter Feed</h1>
        <button
          onClick={reset}
          style={{
            padding: '8px 16px',
            backgroundColor: '#1DA1F2',
            color: 'white',
            border: 'none',
            borderRadius: '20px',
            cursor: 'pointer',
          }}
        >
          Refresh
        </button>
      </div>

      {/* Tweet list */}
      <div style={{ display: 'flex', flexDirection: 'column', gap: '15px' }}>
        {tweets.map((tweet) => (
          <div
            key={tweet.id}
            style={{
              border: '1px solid #e1e8ed',
              borderRadius: '12px',
              padding: '15px',
              backgroundColor: 'white',
            }}
          >
            <div style={{ 
              display: 'flex', 
              alignItems: 'center',
              marginBottom: '10px',
            }}>
              <div
                style={{
                  width: '48px',
                  height: '48px',
                  borderRadius: '50%',
                  backgroundColor: '#1DA1F2',
                  display: 'flex',
                  alignItems: 'center',
                  justifyContent: 'center',
                  color: 'white',
                  fontWeight: 'bold',
                  marginRight: '12px',
                }}
              >
                {tweet.author[0].toUpperCase()}
              </div>
              <div>
                <div style={{ fontWeight: 'bold' }}>{tweet.author}</div>
                <div style={{ fontSize: '14px', color: '#657786' }}>
                  {new Date(tweet.timestamp).toLocaleDateString()}
                </div>
              </div>
            </div>
            <p style={{ margin: '10px 0', lineHeight: '1.5' }}>
              {tweet.content}
            </p>
            <div style={{ fontSize: '14px', color: '#657786' }}>
              ❤️ {tweet.likes} likes
            </div>
          </div>
        ))}
      </div>

      {/* Sentinel element */}
      <div
        ref={sentinelRef}
        style={{
          height: '20px',
          margin: '20px 0',
        }}
      />

      {/* Loading indicator */}
      {loading && (
        <div style={{ 
          textAlign: 'center', 
          padding: '20px',
          color: '#657786',
        }}>
          <div
            style={{
              width: '40px',
              height: '40px',
              border: '4px solid #f3f3f3',
              borderTop: '4px solid #1DA1F2',
              borderRadius: '50%',
              animation: 'spin 1s linear infinite',
              margin: '0 auto',
            }}
          />
          <p style={{ marginTop: '10px' }}>Loading more tweets...</p>
        </div>
      )}

      {/* Error state */}
      {error && (
        <div
          style={{
            backgroundColor: '#ffebee',
            border: '1px solid #f44336',
            borderRadius: '8px',
            padding: '15px',
            textAlign: 'center',
          }}
        >
          <p style={{ color: '#c62828', margin: '0 0 10px 0' }}>
            {error}
          </p>
          <button
            onClick={retry}
            style={{
              padding: '8px 16px',
              backgroundColor: '#f44336',
              color: 'white',
              border: 'none',
              borderRadius: '4px',
              cursor: 'pointer',
            }}
          >
            Retry
          </button>
        </div>
      )}

      {/* End of feed */}
      {!hasMore && !loading && (
        <div
          style={{
            textAlign: 'center',
            padding: '20px',
            color: '#657786',
          }}
        >
          You've reached the end! 🎉
        </div>
      )}

      {/* CSS animation */}
      <style>{`
        @keyframes spin {
          0% { transform: rotate(0deg); }
          100% { transform: rotate(360deg); }
        }
      `}</style>
    </div>
  );
}

export default TwitterFeed;

# JavaScript Version

javascript
import useInfiniteScroll from './hooks/useInfiniteScroll';

async function fetchTweets(page, signal) {
  const response = await fetch(
    `https://api.example.com/tweets?page=${page}&limit=20`,
    { signal }
  );

  if (!response.ok) {
    throw new Error(`Failed to fetch tweets: ${response.statusText}`);
  }

  const data = await response.json();
  return data.tweets;
}

function TwitterFeed() {
  const {
    items: tweets,
    loading,
    error,
    hasMore,
    sentinelRef,
    retry,
    reset,
  } = useInfiniteScroll(fetchTweets, {
    threshold: 0.5,
    rootMargin: '100px',
  });

  return (
    <div style={{ maxWidth: '600px', margin: '0 auto', padding: '20px' }}>
      {/* Same JSX as TypeScript version */}
    </div>
  );
}

export default TwitterFeed;

# FAQ

# Q: Should I use IntersectionObserver or scroll events?

A: Always use IntersectionObserver in production. Scroll events fire hundreds of times per second, requiring throttling/debouncing. IntersectionObserver is:

  • More performant (runs off main thread)
  • Battery-friendly on mobile
  • Simpler to implement correctly
  • Natively handles complex layouts

Only use scroll events if you need IE11 support and can't use a polyfill.

# Q: How do I handle filtering or search with infinite scroll?

A: Reset the list when filter changes:

typescript
const [searchTerm, setSearchTerm] = useState('');
const { items, reset, sentinelRef } = useInfiniteScroll(
  (page, signal) => fetchFiltered(page, searchTerm, signal)
);

useEffect(() => {
  reset(); // Clear and restart when searchTerm changes
}, [searchTerm, reset]);

# Q: What's the best threshold and rootMargin values?

A: It depends on use case:

typescript
// Conservative (less aggressive prefetch)
{ threshold: 1.0, rootMargin: '0px' }

// Moderate (recommended for most cases)
{ threshold: 0.5, rootMargin: '100px' }

// Aggressive (faster connections, smooth experience)
{ threshold: 0.1, rootMargin: '300px' }

Rule of thumb:

  • Slow connections: Smaller rootMargin (100px)
  • Fast connections: Larger rootMargin (300-500px)
  • Image-heavy content: Larger rootMargin (500px+)

# Q: How do I implement "Load More" button instead of automatic?

A: Detach observer and load manually:

typescript
function InfiniteWithButton() {
  const { items, loading, loadMore, hasMore } = useInfiniteScroll(fetchItems);

  return (
    <div>
      {items.map(item => <Item key={item.id} {...item} />)}
      
      {hasMore && (
        <button onClick={loadMore} disabled={loading}>
          {loading ? 'Loading...' : 'Load More'}
        </button>
      )}
    </div>
  );
}

# Q: How do I prevent memory issues with thousands of items?

A: Three approaches:

1. Virtualization (best for uniform items):

typescript
import { FixedSizeList } from 'react-window';

<FixedSizeList
  height={600}
  itemCount={items.length}
  itemSize={100}
  width="100%"
>
  {({ index, style }) => (
    <div style={style}>{items[index].title}</div>
  )}
</FixedSizeList>

2. Pruning (simple approach):

typescript
useEffect(() => {
  if (items.length > 500) {
    setItems(prev => prev.slice(-300)); // Keep last 300
  }
}, [items.length]);

3. Pagination fallback:

typescript
if (items.length > 1000) {
  // Switch to traditional pagination
}

# Q: Can I use infinite scroll with React Router?

A: Yes, but save scroll position:

typescript
import { useLocation } from 'react-router-dom';

function InfiniteFeed() {
  const location = useLocation();
  const { items } = useInfiniteScroll(fetchItems);

  useEffect(() => {
    // Save scroll position
    return () => {
      sessionStorage.setItem(
        `scroll-${location.pathname}`,
        window.scrollY.toString()
      );
    };
  }, [location.pathname]);

  useEffect(() => {
    // Restore scroll position
    const saved = sessionStorage.getItem(`scroll-${location.pathname}`);
    if (saved) {
      window.scrollTo(0, parseInt(saved, 10));
    }
  }, [location.pathname]);

  return <div>{/* Feed content */}</div>;
}

Related Articles:

Questions? Share your infinite scroll implementations! How do you handle edge cases like rapid scrolling or slow connections?

Sponsored Content

Google AdSense Placeholder

CONTENT SLOT

Sponsored

Google AdSense Placeholder

FOOTER SLOT