javascript
64 lines · 10 steps
Infinite scroll with IntersectionObserver
A small class watches a sentinel element and fetches the next page whenever it scrolls into view.
Explained by
highlit
1class InfiniteScroll {
2 constructor(sentinel, { loadMore, root = null, rootMargin = '200px' } = {}) {
3 this.sentinel = sentinel;
4 this.loadMore = loadMore;
5 this.page = 1;
6 this.loading = false;
7 this.done = false;
8
9 this.observer = new IntersectionObserver(
10 (entries) => this.handleIntersect(entries),
11 { root, rootMargin, threshold: 0 }
12 );
13 this.observer.observe(this.sentinel);
14 }
15
16 async handleIntersect(entries) {
17 const entry = entries[0];
18 if (!entry.isIntersecting || this.loading || this.done) return;
19
20 this.loading = true;
21 this.sentinel.classList.add('loading');
22
23 try {
24 const { items, hasMore } = await this.loadMore(this.page);
25 if (items.length) this.page += 1;
26 if (!hasMore) this.stop();
27 } catch (err) {
28 console.error('Failed to load more items', err);
29 } finally {
30 this.loading = false;
31 this.sentinel.classList.remove('loading');
32 }
33 }
34
35 stop() {
36 this.done = true;
37 this.observer.unobserve(this.sentinel);
38 }
39
40 disconnect() {
41 this.observer.disconnect();
42 }
43}
44
45const list = document.querySelector('#feed');
46const sentinel = document.querySelector('#sentinel');
47
48new InfiniteScroll(sentinel, {
49 async loadMore(page) {
50 const res = await fetch(`/api/posts?page=${page}`);
51 const { posts, hasMore } = await res.json();
52
53 const fragment = document.createDocumentFragment();
54 for (const post of posts) {
55 const el = document.createElement('article');
56 el.className = 'post';
57 el.innerHTML = `<h2>${post.title}</h2><p>${post.excerpt}</p>`;
58 fragment.append(el);
59 }
60 list.append(fragment);
61
62 return { items: posts, hasMore };
63 },
64});
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1IntersectionObserver turns scroll-position tracking into a cheap, callback-driven signal instead of a scroll listener.
- 2Guard flags like loading and done prevent overlapping fetches and stop work once the data runs out.
- 3A try/finally around the async fetch guarantees state and UI are reset even when a request fails.
Related explainers
rust
use std::sync::Arc; use std::time::Duration; use axum::{
Long-polling with Axum and Tokio Notify
long-polling
concurrency
shared-state
Advanced
8 steps
php
<?php namespace App\Http\Controllers\Api;
Cursor pagination in a Laravel API
pagination
cursor
keyset
Intermediate
9 steps
javascript
import { useEffect, useRef } from "react"; import { useBlocker } from "react-router-dom"; export function useUnsavedChangesPrompt(isDirty, message = "You have unsaved changes. Leave anyway?") {
Guarding unsaved changes with a React hook
custom-hooks
navigation-guard
event-listeners
Intermediate
7 steps
typescript
type AsyncMethod = (...args: any[]) => Promise<any>; function LogExecutionTime(thresholdMs = 0) { return function <T extends AsyncMethod>(
A method decorator that times async calls
decorators
higher-order-functions
async
Advanced
9 steps
javascript
function deepFreeze(obj) { const propNames = Reflect.ownKeys(obj); for (const name of propNames) {
Recursively freezing a nested object
recursion
immutability
object-freezing
Intermediate
6 steps
javascript
import { forwardRef, useImperativeHandle, useRef, useState } from 'react'; const TextField = forwardRef(function TextField( { label, error, onChange, ...props },
Exposing an imperative handle in React
forwardref
useimperativehandle
refs
Advanced
8 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/infinite-scroll-with-intersectionobserver-explained-javascript-c409/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.