DEV Community

Saurabh Raj
Saurabh Raj

Posted on

Mastering Infinite Scroll in JS and React with optimisation techniques - Never to look back again

Introduction

In today's digital landscape, users expect seamless browsing experiences that don't interrupt their content consumption. Infinite scroll has emerged as a powerful pattern that keeps users engaged by automatically loading new content as they scroll to the bottom of a page. In this guide, we'll explore different approaches to implementing infinite scroll, from vanilla JavaScript to React, along with optimization techniques to ensure smooth performance.

What is Infinite Scroll?

Infinite scroll is a web design technique that loads content continuously as the user scrolls down, eliminating the need for pagination. Instead of clicking "next page" buttons, users simply keep scrolling, and new content appears automatically. This creates a frictionless experience that encourages longer engagement with your content.

Popular platforms like Instagram, Twitter, and Facebook have popularised this pattern for good reason—it keeps users in a continuous flow state while browsing.

Understanding the Building Blocks

Before we dive into implementation, let's understand the key components that make infinite scroll work:

  1. Scroll detection: Identifying when a user has reached or approached the bottom of the page.
  2. Content fetching: Loading new data, typically through an API call.
  3. DOM manipulation: Adding the new content to the existing page.
  4. Performance optimization: Ensuring the page remains responsive, even with large amounts of content.

Vanilla JavaScript Implementation

Let's start with a pure JavaScript approach, which helps understand the core mechanics before moving to frameworks.

1. Using the Intersection Observer API

The Intersection Observer API provides an elegant way to detect when an element enters the viewport. It's perfect for infinite scroll because it's more performant than traditional scroll event listeners:

// First, create an element that we'll observe
const loadingElement = document.createElement('div');
loadingElement.id = 'loading-indicator';
loadingElement.textContent = 'Loading more content...';
document.body.appendChild(loadingElement);

// Set up our observer options
const options = {
  root: null, // Use the viewport as root
  rootMargin: '0px 0px 200px 0px', // Trigger 200px before the element is visible
  threshold: 0.1 // Trigger when 10% of the element is visible
};

// Create the observer
let page = 1;
let isLoading = false;
const observer = new IntersectionObserver((entries) => {
  const entry = entries[0];

  if (entry.isIntersecting && !isLoading) {
    loadMoreContent();
  }
}, options);

// Start observing the loading element
observer.observe(loadingElement);

// Function to load more content
async function loadMoreContent() {
  try {
    isLoading = true;

    // Fetch new data (in a real app, this would be an API call)
    const response = await fetch(`/api/content?page=${page}`);
    const newItems = await response.json();

    if (newItems.length === 0) {
      // No more items to load
      observer.disconnect();
      loadingElement.textContent = 'No more content';
      return;
    }

    // Add the new items to the page
    const contentContainer = document.getElementById('content');
    newItems.forEach(item => {
      const itemElement = document.createElement('div');
      itemElement.classList.add('item');
      itemElement.innerHTML = `
        <h2>${item.title}</h2>
        <p>${item.description}</p>
      `;
      contentContainer.appendChild(itemElement);
    });

    // Update page counter and reset loading state
    page++;
    isLoading = false;
  } catch (error) {
    console.error('Error loading content:', error);
    isLoading = false;
  }
}
Enter fullscreen mode Exit fullscreen mode

This approach is clean and efficient because:

  • The Intersection Observer doesn't fire on every scroll event, only when the observed element enters or exits the viewport.
  • We can control when the loading begins with the rootMargin property, triggering the load before the user actually reaches the bottom.

2. The Classic Scroll Event Approach

Before Intersection Observer, developers relied on the scroll event. While less efficient, you might still need this approach for broader browser compatibility:

let page = 1;
let isLoading = false;
let hasMoreContent = true;
const loadingElement = document.getElementById('loading-indicator');

// Debounce function to limit scroll event firing
function debounce(func, delay) {
  let timeout;
  return function() {
    clearTimeout(timeout);
    timeout = setTimeout(() => func.apply(this, arguments), delay);
  };
}

// Check if we need to load more content
function checkScroll() {
  if (isLoading || !hasMoreContent) return;

  const scrollPosition = window.scrollY + window.innerHeight;
  const documentHeight = document.documentElement.scrollHeight;

  // If we're close to the bottom of the page
  if (documentHeight - scrollPosition < 200) {
    loadMoreContent();
  }
}

// Add the scroll listener with debounce
window.addEventListener('scroll', debounce(checkScroll, 100));

// Initial check in case the page isn't tall enough to scroll
checkScroll();
Enter fullscreen mode Exit fullscreen mode

The key differences with this approach:

  • We're manually calculating scroll position relative to page height.
  • We need to debounce the scroll event to prevent performance issues.
  • It's generally more CPU-intensive than Intersection Observer.

React Implementation: Declarative Infinite Scrolling

React's component-based approach allows for more declarative implementations of infinite scroll. Here's how to build a reusable infinite scroll component:

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

function InfiniteScroll({ fetchData, renderItem }) {
  const [items, setItems] = useState([]);
  const [page, setPage] = useState(1);
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);
  const observer = useRef();

  // Set up the loading element ref using useCallback to avoid unnecessary re-creation
  const lastElementRef = useCallback(node => {
    if (loading) return;

    if (observer.current) observer.current.disconnect();

    observer.current = new IntersectionObserver(entries => {
      if (entries[0].isIntersecting && hasMore) {
        loadMore();
      }
    });

    if (node) observer.current.observe(node);
  }, [loading, hasMore]);

  // Function to load more data
  const loadMore = async () => {
    if (loading || !hasMore) return;

    setLoading(true);

    try {
      const newItems = await fetchData(page);

      if (newItems.length === 0) {
        setHasMore(false);
      } else {
        setItems(prevItems => [...prevItems, ...newItems]);
        setPage(prevPage => prevPage + 1);
      }
    } catch (error) {
      console.error('Error loading more items:', error);
    } finally {
      setLoading(false);
    }
  };

  // Load initial data
  useEffect(() => {
    loadMore();

    // Clean up observer on unmount
    return () => {
      if (observer.current) {
        observer.current.disconnect();
      }
    };
  }, []);

  return (
    <div className="infinite-scroll-container">
      <div className="items-list">
        {items.map((item, index) => {
          // Add ref to last element
          if (items.length - 1 === index) {
            return (
              <div ref={lastElementRef} key={item.id}>
                {renderItem(item)}
              </div>
            );
          } else {
            return <div key={item.id}>{renderItem(item)}</div>;
          }
        })}
      </div>

      {loading && (
        <div className="loading-indicator">
          <div className="spinner"></div>
          <p>Loading more content...</p>
        </div>
      )}

      {!hasMore && !loading && (
        <div className="end-message">
          <p>You've reached the end!</p>
        </div>
      )}
    </div>
  );
}

export default InfiniteScroll;
Enter fullscreen mode Exit fullscreen mode

Using with Custom Hooks

We can extract the logic into a custom hook for better reusability:

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

export function useInfiniteScroll(fetchCallback, options = {}) {
  const { initialPage = 1, threshold = '200px' } = options;

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

  // Create the observer ref
  const observer = useRef();

  // Reset function
  const reset = useCallback(() => {
    setItems([]);
    setPage(initialPage);
    setLoading(false);
    setHasMore(true);
    setError(null);
  }, [initialPage]);

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

    setLoading(true);
    setError(null);

    try {
      const result = await fetchCallback(page);
      const newItems = result.items || result;

      if (newItems.length === 0 || (result.hasMore === false)) {
        setHasMore(false);
      } else {
        setItems(prev => [...prev, ...newItems]);
        setPage(prev => prev + 1);
      }
    } catch (err) {
      setError(err);
    } finally {
      setLoading(false);
    }
  }, [fetchCallback, page, loading, hasMore]);

  // The ref callback for the last element
  const lastElementRef = useCallback(node => {
    if (loading) return;

    if (observer.current) observer.current.disconnect();

    observer.current = new IntersectionObserver(entries => {
      if (entries[0].isIntersecting && hasMore) {
        loadMore();
      }
    }, {
      rootMargin: `0px 0px ${threshold} 0px`
    });

    if (node) observer.current.observe(node);
  }, [loading, hasMore, threshold, loadMore]);

  // Initial load effect
  useEffect(() => {
    loadMore();

    return () => {
      if (observer.current) {
        observer.current.disconnect();
      }
    };
  }, []);

  return {
    items,
    loading,
    hasMore,
    error,
    loadMore,
    reset,
    lastElementRef
  };
}
Enter fullscreen mode Exit fullscreen mode

Advanced Optimization Techniques

As your infinite scroll implementation grows, optimization becomes crucial. Here are some advanced techniques to keep your application running smoothly.

1. Virtualized Lists for Large Datasets

When dealing with hundreds or thousands of items, rendering them all to the DOM can severely impact performance. Virtualization renders only the items currently visible in the viewport, plus a small buffer.

In React, libraries like react-window and react-virtualized make this easy:

import React from 'react';
import { FixedSizeList } from 'react-window';
import InfiniteLoader from 'react-window-infinite-loader';

function VirtualizedInfiniteScroll({ items, hasMore, loadMore, isItemLoaded }) {
  // Item renderer
  const Item = ({ index, style }) => {
    if (!isItemLoaded(index)) {
      return (
        <div style={style} className="loading-item">
          <div className="skeleton-loader"></div>
        </div>
      );
    }

    const item = items[index];
    return (
      <div style={style} className="list-item">
        <h3>{item.title}</h3>
        <p>{item.description}</p>
      </div>
    );
  };

  return (
    <InfiniteLoader
      isItemLoaded={isItemLoaded}
      itemCount={hasMore ? items.length + 1 : items.length}
      loadMoreItems={loadMore}
    >
      {({ onItemsRendered, ref }) => (
        <FixedSizeList
          ref={ref}
          height={500}
          width="100%"
          itemCount={hasMore ? items.length + 1 : items.length}
          itemSize={150}
          onItemsRendered={onItemsRendered}
        >
          {Item}
        </FixedSizeList>
      )}
    </InfiniteLoader>
  );
}
Enter fullscreen mode Exit fullscreen mode

Key benefits of virtualization:

  • Drastically reduces DOM elements (from potentially thousands to just a few dozen).
  • Improves scrolling performance and reduces memory usage.
  • Prevents layout thrashing when rendering large lists.

2. Implementing Cursor-Based Pagination

Instead of using page numbers, cursor-based pagination uses a "pointer" to the last item fetched. This is more robust and efficient, especially for dynamic data:

async function fetchItems(cursor = null, limit = 20) {
  const url = new URL('/api/items', window.location.origin);

  // Add parameters
  url.searchParams.append('limit', limit);
  if (cursor) {
    url.searchParams.append('cursor', cursor);
  }

  const response = await fetch(url);
  const data = await response.json();

  return {
    items: data.items,
    nextCursor: data.nextCursor,
    hasMore: Boolean(data.nextCursor)
  };
}
Enter fullscreen mode Exit fullscreen mode

Benefits of cursor-based pagination:

  • Handles insertions and deletions gracefully (unlike offset pagination).
  • More efficient database queries (using indexed lookups instead of offsets).
  • Prevents duplicate items when new content is added during pagination.

3. Implementing DOM Cleanup for Long Sessions

For very long scrolling sessions, the DOM can become bloated with off-screen content. Implementing a cleanup strategy keeps memory usage in check:

function InfiniteScrollWithCleanup() {
  const [visibleItems, setVisibleItems] = useState([]);
  const [allItems, setAllItems] = useState([]);
  const [visibleRange, setVisibleRange] = useState({ start: 0, end: 20 });
  const containerRef = useRef(null);

  // Load more items function (similar to previous examples)

  // Handle scroll to update visible range
  const handleScroll = useCallback(() => {
    if (!containerRef.current) return;

    const { scrollTop, clientHeight } = containerRef.current;
    const itemHeight = 150; // Approximate height of each item
    const bufferSize = 5; // Number of items to keep before and after viewport

    // Calculate which items should be visible
    const start = Math.max(0, Math.floor(scrollTop / itemHeight) - bufferSize);
    const end = Math.min(
      allItems.length - 1,
      Math.ceil((scrollTop + clientHeight) / itemHeight) + bufferSize
    );

    // Update visible range
    setVisibleRange({ start, end });
  }, [allItems.length]);

  // When visible range changes, update visible items
  useEffect(() => {
    const itemsToRender = allItems.slice(visibleRange.start, visibleRange.end + 1);
    setVisibleItems(itemsToRender);
  }, [visibleRange, allItems]);

  // Set up scroll listener
  useEffect(() => {
    const container = containerRef.current;
    if (container) {
      container.addEventListener('scroll', handleScroll);
      return () => container.removeEventListener('scroll', handleScroll);
    }
  }, [handleScroll]);

  return (
    <div
      ref={containerRef}
      style={{ height: '600px', overflow: 'auto' }}
    >
      {/* Container with full height */}
      <div style={{ height: `${allItems.length * 150}px`, position: 'relative' }}>
        {/* Only render visible items */}
        {visibleItems.map(item => (
          <div
            key={item.id}
            style={{
              position: 'absolute',
              top: `${allItems.findIndex(i => i.id === item.id) * 150}px`,
              height: '150px',
              width: '100%'
            }}
          >
            <h3>{item.title}</h3>
            <p>{item.description}</p>
          </div>
        ))}
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The benefits of this approach:

  • Keeps the DOM light by removing off-screen elements.
  • Maintains correct scroll position using absolute positioning.
  • Significantly improves performance for very long lists.

Enhancing User Experience

A great infinite scroll implementation goes beyond just loading content. Here are some UX enhancements to consider:

1. Skeleton Loading States

Show placeholder content while items are loading:

function SkeletonLoader() {
  return (
    <div className="skeleton-item">
      <div className="skeleton-title"></div>
      <div className="skeleton-text"></div>
      <div className="skeleton-text"></div>
    </div>
  );
}

// In your component
{loading && Array.from({ length: 3 }).map((_, i) => (
  <SkeletonLoader key={`skeleton-${i}`} />
))}
Enter fullscreen mode Exit fullscreen mode

Add some CSS animation:

.skeleton-item {
  padding: 15px;
  margin-bottom: 10px;
  border-radius: 4px;
  background: #f8f8f8;
}

.skeleton-title {
  height: 24px;
  background: linear-gradient(90deg, #eee 0%, #f5f5f5 50%, #eee 100%);
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
  border-radius: 4px;
  margin-bottom: 10px;
}

.skeleton-text {
  height: 16px;
  background: linear-gradient(90deg, #eee 0%, #f5f5f5 50%, #eee 100%);
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
  border-radius: 4px;
  margin-bottom: 8px;
  width: 80%;
}

@keyframes shimmer {
  0% { background-position: -200% 0; }
  100% { background-position: 200% 0; }
}
Enter fullscreen mode Exit fullscreen mode

2. Scroll Position Recovery

Maintain scroll position after browser refresh or navigating back:

// Save scroll position before unloading
useEffect(() => {
  const handleBeforeUnload = () => {
    sessionStorage.setItem('scrollPosition', window.scrollY.toString());
    sessionStorage.setItem('items', JSON.stringify(items));
  };

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

// Restore position on mount
useEffect(() => {
  const savedPosition = sessionStorage.getItem('scrollPosition');
  const savedItems = sessionStorage.getItem('items');

  if (savedItems) {
    setItems(JSON.parse(savedItems));
  }

  if (savedPosition) {
    window.scrollTo(0, parseInt(savedPosition, 10));
  }
}, []);
Enter fullscreen mode Exit fullscreen mode

3. Pull-to-Refresh for Mobile

Add pull-to-refresh functionality for mobile users:

function PullToRefresh({ onRefresh, children }) {
  const [isPulling, setIsPulling] = useState(false);
  const [pullDistance, setPullDistance] = useState(0);
  const startY = useRef(0);
  const containerRef = useRef(null);

  const handleTouchStart = (e) => {
    // Only enable pull-to-refresh at the top of the page
    if (window.scrollY === 0) {
      startY.current = e.touches[0].clientY;
    }
  };

  const handleTouchMove = (e) => {
    if (startY.current === 0 || window.scrollY > 0) return;

    const currentY = e.touches[0].clientY;
    const distance = currentY - startY.current;

    // Only allow pulling down
    if (distance > 0) {
      setPullDistance(distance * 0.5); // Resistance factor
      setIsPulling(true);
      e.preventDefault();
    }
  };

  const handleTouchEnd = async () => {
    if (pullDistance > 70) {
      // Enough distance to trigger refresh
      try {
        await onRefresh();
      } catch (error) {
        console.error('Refresh failed:', error);
      }
    }

    // Reset state
    setPullDistance(0);
    setIsPulling(false);
    startY.current = 0;
  };

  return (
    <div
      ref={containerRef}
      onTouchStart={handleTouchStart}
      onTouchMove={handleTouchMove}
      onTouchEnd={handleTouchEnd}
      style={{ transform: `translateY(${pullDistance}px)` }}
    >
      {isPulling && (
        <div 
          className="pull-indicator"
          style={{ 
            height: '60px', 
            opacity: Math.min(pullDistance / 70, 1) 
          }}
        >
          {pullDistance > 70 ? 'Release to refresh' : 'Pull to refresh'}
        </div>
      )}
      {children}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Common Pitfalls and How to Avoid Them

Even with careful implementation, infinite scroll can introduce challenges:

1. SEO Considerations

Infinite scroll can be problematic for search engines. Consider these solutions:

  • Implement hybrid pagination/infinite scroll where each "page" has a unique URL.
  • Use the History API to update the URL as content loads.
  • Create a sitemap with links to all content pages.
  • Implement server-side rendering for initial content.

2. Memory Leaks

Long browsing sessions can lead to memory issues:

  • Implement DOM cleanup as discussed earlier.
  • Use virtualization for very large lists.
  • Properly clean up event listeners and observers in useEffect cleanup functions.
  • Profile your application to identify memory issues.

3. Accessibility Concerns

Infinite scroll can be challenging for users with disabilities:

  • Add keyboard navigation support.
  • Include a visible loading indicator with ARIA attributes.
  • Provide a "Skip to content" option.
  • Consider adding a traditional pagination option as an alternative.

Conclusion: The Most Elegant Approach

The most elegant infinite scroll implementation combines several approaches:

  • Use Intersection Observer for efficient scroll detection.
  • Implement virtualization for large datasets to keep the DOM light.
  • Utilize cursor-based pagination for robust data fetching.
  • Add DOM cleanup for lengthy browsing sessions.
  • Enhance with user experience features like skeleton loading and scroll position recovery.
  • Address accessibility concerns to create an inclusive experience.

Here's a simplified example of what this might look like in a React application:

import React, { useState, useCallback } from 'react';
import { FixedSizeList } from 'react-window';
import InfiniteLoader from 'react-window-infinite-loader';
import SkeletonLoader from './SkeletonLoader';

function ElegantInfiniteScroll() {
  const [items, setItems] = useState([]);
  const [cursor, setCursor] = useState(null);
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);

  // Fetch items with cursor pagination
  const loadMoreItems = useCallback(async () => {
    if (loading || !hasMore) return;

    setLoading(true);

    try {
      const response = await fetch(`/api/items?cursor=${cursor || ''}`);
      const data = await response.json();

      setItems(prev => [...prev, ...data.items]);
      setCursor(data.nextCursor);
      setHasMore(Boolean(data.nextCursor));
    } catch (error) {
      console.error('Error loading items:', error);
    } finally {
      setLoading(false);
    }
  }, [cursor, loading, hasMore]);

  // Check if an item at a given index is loaded
  const isItemLoaded = index => index < items.length;

  // Render an individual item
  const ItemRenderer = ({ index, style }) => {
    if (!isItemLoaded(index)) {
      return (
        <div style={style}>
          <SkeletonLoader />
        </div>
      );
    }

    const item = items[index];

    return (
      <div style={style} className="item">
        <h3>{item.title}</h3>
        <p>{item.description}</p>
      </div>
    );
  };

  return (
    <div className="infinite-scroll-container">
      <h1>Elegant Infinite Scroll</h1>

      <InfiniteLoader
        isItemLoaded={isItemLoaded}
        itemCount={hasMore ? items.length + 1 : items.length}
        loadMoreItems={loadMoreItems}
        threshold={5}
      >
        {({ onItemsRendered, ref }) => (
          <FixedSizeList
            ref={ref}
            height={600}
            width="100%"
            itemCount={hasMore ? items.length + 1 : items.length}
            itemSize={150}
            onItemsRendered={onItemsRendered}
          >
            {ItemRenderer}
          </FixedSizeList>
        )}
      </InfiniteLoader>

      {!hasMore && (
        <div className="end-message">
          <p>You've reached the end of the content!</p>
        </div>
      )}
    </div>
  );
}

export default ElegantInfiniteScroll;
Enter fullscreen mode Exit fullscreen mode

By combining these techniques, you can create an infinite scroll implementation that's performant, accessible, and provides an excellent user experience. The key is to understand your specific use case and apply the right combination of these approaches to meet your needs.

Remember that infinite scroll isn't always the best solution—sometimes traditional pagination is more appropriate, especially for content where users might want to return to a specific position. Choose the right tool for your specific user experience needs.

Top comments (0)