typescript 110 lines · 3 tabs

Infinite-Scroll Feed with a React IntersectionObserver Hook and Cursor Pagination

Shared by codesnips Aug 2026
3 tabs
import { useCallback, useEffect, useRef, useState } from "react";

export function useIntersectionObserver(options?: IntersectionObserverInit) {
  const [isIntersecting, setIsIntersecting] = useState(false);
  const nodeRef = useRef<Element | null>(null);
  const optionsKey = JSON.stringify(options ?? {});

  const ref = useCallback((node: Element | null) => {
    nodeRef.current = node;
  }, []);

  useEffect(() => {
    const node = nodeRef.current;
    if (!node) return;

    const observer = new IntersectionObserver(([entry]) => {
      setIsIntersecting(entry.isIntersecting);
    }, options);

    observer.observe(node);
    return () => observer.disconnect();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [optionsKey, nodeRef.current]);

  return { ref, isIntersecting };
}
3 files · typescript Explain with highlit

An infinite-scroll feed loads more items automatically as a sentinel element scrolls into view, avoiding the extra click of a "Load more" button while keeping only one page of work in flight at a time. This snippet splits that behavior into three collaborating files: a low-level useIntersectionObserver hook that reports when a ref becomes visible, a useInfiniteFeed hook that owns cursor-based pagination state, and a Feed component that wires them together.

In useIntersectionObserver, the hook returns a ref callback and an isIntersecting boolean. Using a ref callback rather than a useRef object is deliberate: it fires whenever the DOM node mounts or unmounts, so the observer is attached and torn down at exactly the right moments even when the sentinel is conditionally rendered. The observer is recreated inside a useEffect keyed on the serialized options, and the cleanup disconnect() prevents leaks and stale callbacks. The rootMargin option lets the trigger fire before the sentinel is fully visible, prefetching the next page early for smoother scrolling.

In useInfiniteFeed, pagination is modeled around a cursor instead of a page number. Cursor pagination is more robust than offset pagination because inserts and deletes near the top of the feed don't shift items or cause duplicates. The loadMore function is wrapped in useCallback and guards against concurrent calls with a loadingRef, so a burst of intersection events can't fire overlapping requests. Responses are appended to items, and nextCursor is stored; when the server returns a null cursor, hasMore flips to false and further loads short-circuit. Errors are captured in state rather than thrown, letting the UI offer a retry without unmounting the list.

In Feed, the two hooks meet: the observer's isIntersecting value is watched by an effect that calls loadMore, and the sentinel div receives the observer's ref. Because loadMore is stable and self-guarding, the effect can depend on it safely without re-triggering loops. The generic fetchPage prop keeps the feed reusable across different item types. Together these pieces form a small, testable pattern: observation, pagination, and rendering are each isolated and independently replaceable.


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
typescript
export type Settled<R> =
  | { status: 'fulfilled'; value: R }
  | { status: 'rejected'; reason: unknown };

export interface ConcurrencyOptions {
  limit: number;

Simple concurrency limiter for batch operations

node concurrency async
by codesnips 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 Feed with a React IntersectionObserver Hook and Cursor Pagination — share card
Link copied