Front-end Engineering Lab

Infinite Scroll Observer

Automatically load more data when the user reaches the end of a list using IntersectionObserver.

Infinite Scroll Observer

Problem

Traditional infinite scroll implementations use scroll event listeners with manual calculations, which are inefficient and difficult to maintain.

Solution

Use IntersectionObserver to detect when a sentinel element (last item or loading indicator) enters the viewport, then trigger the next page load.

interface InfiniteScrollConfig {
  root?: HTMLElement | null;
  rootMargin?: string;
  threshold?: number;
}

interface PageData<T> {
  items: T[];
  hasMore: boolean;
  nextCursor?: string;
}

class InfiniteScrollManager<T> {
  private observer: IntersectionObserver | null = null;
  private loading = false;
  private hasMore = true;
  private currentPage = 0;

  constructor(
    private sentinel: HTMLElement,
    private fetchData: (page: number) => Promise<PageData<T>>,
    private onDataLoaded: (items: T[], isFirstLoad: boolean) => void,
    private onError: (error: Error) => void,
    config: InfiniteScrollConfig = {}
  ) {
    this.initialize(config);
  }

  private initialize(config: InfiniteScrollConfig): void {
    const options: IntersectionObserverInit = {
      root: config.root || null,
      rootMargin: config.rootMargin || '100px',
      threshold: config.threshold || 0.1,
    };

    this.observer = new IntersectionObserver((entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting && !this.loading && this.hasMore) {
          this.loadMore();
        }
      });
    }, options);

    this.observer.observe(this.sentinel);
  }

  private async loadMore(): Promise<void> {
    if (this.loading || !this.hasMore) return;

    this.loading = true;
    this.currentPage++;

    try {
      const result = await this.fetchData(this.currentPage);

      this.hasMore = result.hasMore;
      this.onDataLoaded(result.items, this.currentPage === 1);

      if (!this.hasMore) {
        this.disconnect();
      }
    } catch (error) {
      this.currentPage--; // Rollback page on error
      this.onError(error instanceof Error ? error : new Error('Unknown error'));
    } finally {
      this.loading = false;
    }
  }

  public reset(): void {
    this.currentPage = 0;
    this.hasMore = true;
    this.loading = false;
  }

  public disconnect(): void {
    if (this.observer) {
      this.observer.disconnect();
      this.observer = null;
    }
  }
}

// Practical example
interface Post {
  id: string;
  title: string;
  content: string;
}

async function fetchPosts(page: number): Promise<PageData<Post>> {
  const response = await fetch(`/api/posts?page=${page}&limit=20`);

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

  const data = await response.json() as { posts: Post[]; hasMore: boolean };
  
  return {
    items: data.posts,
    hasMore: data.hasMore,
  };
}

function setupInfiniteScroll(
  container: HTMLElement,
  sentinel: HTMLElement
): InfiniteScrollManager<Post> {
  const renderPosts = (posts: Post[], isFirstLoad: boolean): void => {
    if (isFirstLoad) {
      container.innerHTML = '';
    }

    posts.forEach((post) => {
      const article = document.createElement('article');
      article.className = 'post';
      article.innerHTML = `
        <h2>${post.title}</h2>
        <p>${post.content}</p>
      `;
      container.appendChild(article);
    });
  };

  const handleError = (error: Error): void => {
    console.error('Failed to load posts:', error);
    const errorElement = document.createElement('div');
    errorElement.className = 'error';
    errorElement.textContent = 'Failed to load more posts. Please try again.';
    container.appendChild(errorElement);
  };

  return new InfiniteScrollManager(
    sentinel,
    fetchPosts,
    renderPosts,
    handleError,
    { rootMargin: '200px' }
  );
}

Performance Note

Using IntersectionObserver is far more efficient than scroll listeners:

  • No manual calculations: the browser handles intersection detection natively
  • Automatic throttling: the browser optimizes callback execution
  • Better battery life: fewer JavaScript executions on mobile

The rootMargin: '200px' setting starts loading the next page 200px before the user reaches the sentinel, creating a seamless infinite scroll experience with no visible loading states.

This pattern can handle lists with millions of items by loading only 20-50 at a time, keeping memory usage low and scroll performance smooth.

On this page