typescript 113 lines · 3 tabs

Infinite-Scroll List in React with IntersectionObserver and Cursor Pagination

Shared by codesnips Jul 2026
3 tabs
export interface Page<T> {
  items: T[];
  nextCursor: string | null;
}

export interface Post {
  id: string;
  title: string;
  author: string;
}

export async function fetchPage(
  cursor: string | null,
  signal: AbortSignal
): Promise<Page<Post>> {
  const params = new URLSearchParams({ limit: "20" });
  if (cursor) params.set("cursor", cursor);

  const res = await fetch(`/api/posts?${params.toString()}`, { signal });
  if (!res.ok) {
    throw new Error(`Failed to load posts: ${res.status}`);
  }
  return (await res.json()) as Page<Post>;
}
3 files · typescript Explain with highlit

This snippet builds an infinite-scroll list the way it tends to look in a real React codebase: a small typed API helper, a reusable data hook that tracks cursor pagination, and a component that wires a sentinel element to an IntersectionObserver. The pieces are split so the fetching logic stays testable and the component stays declarative.

In api.ts, fetchPage wraps a cursor-based endpoint. Cursor pagination is preferred over offset pagination for feeds because it is stable under inserts and deletes — a nextCursor opaque token always points at the same logical position, whereas ?page=2 shifts when rows change. The function accepts an AbortSignal so in-flight requests can be cancelled, and it returns a typed Page<T> carrying the items plus the next cursor.

In useInfiniteList hook, the state machine is kept deliberately small: items, a cursor, a loading flag, and a done flag once the server stops returning a cursor. loadMore is guarded by a loadingRef so overlapping calls — which are easy to trigger with a fast-scrolling observer — collapse into one request. Each call creates an AbortController and the cleanup effect aborts any pending fetch on unmount, avoiding the classic "set state on an unmounted component" warning and wasted bandwidth. The hook only appends when the request succeeds and ignores AbortError, so cancellation is not treated as a failure.

In InfiniteList component, a sentinelRef marks an empty div at the bottom of the list. An IntersectionObserver watches that node with a rootMargin so loading starts slightly before the sentinel is visible, giving the network time to respond before the user hits the bottom. The observer is recreated inside useEffect and disconnected on cleanup, and it calls loadMore only while not loading and not done. Rendering keys on a stable id rather than array index so React reconciliation stays correct as items append.

The main trade-offs: IntersectionObserver needs a real sentinel in the DOM, and very short lists that never scroll will not trigger a second fetch — a rootMargin or an initial "fill the viewport" pass addresses that. This pattern is the right reach when a feed grows unbounded and offset counts are unreliable or expensive.


Related snips

typescript
export interface RetryOptions {
  retries: number;
  baseMs: number;
  maxMs: number;
  signal?: AbortSignal;
  onRetry?: (attempt: number, delay: number, err: unknown) => void;

Exponential backoff with jitter for retries

typescript reliability retry
by codesnips 2 tabs
ruby
class PostsController < ApplicationController
  def index
    @posts = Post.includes(:author)
                 .order(created_at: :desc)
                 .page(params[:page])
                 .per(10)

Turbo Frames: infinite scroll with lazy-loading frame

rails turbo hotwire
by codesnips 4 tabs
javascript
import { Controller } from "@hotwired/stimulus"
import Mousetrap from "mousetrap"

export default class extends Controller {
  connect() {
    // Global shortcuts

Keyboard shortcuts with Stimulus and Mousetrap

stimulus javascript ux
by Jordan Lee 2 tabs
javascript
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["form"]
  static values = { delay: { type: Number, default: 250 } }

Debounced live search with Stimulus + Turbo Streams

rails hotwire stimulus
by codesnips 4 tabs
typescript
import axios, { AxiosError } from 'axios'
import { v4 as uuidv4 } from 'uuid'

const api = axios.create({
  baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1',
  timeout: 15000,

Axios API client with interceptors

react axios api
by Maya Patel 1 tab
typescript
import { create } from 'zustand'
import { persist } from 'zustand/middleware'

interface FilterState {
  search: string
  category: string | null

Zustand for lightweight state management

react zustand state-management
by Maya Patel 2 tabs

Share this code

Here's the card — post it anywhere.

Infinite-Scroll List in React with IntersectionObserver and Cursor Pagination — share card
Link copied