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.