Infinite Scroll Observer
Automatically load more data when the user reaches the end of a list using IntersectionObserver.
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.