javascript 119 lines · 3 tabs

Infinite Scroll in React with IntersectionObserver and a Paginated Fetch Hook

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

export function usePaginatedFetch(fetchPage, { pageSize = 20 } = {}) {
  const [items, setItems] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const [hasMore, setHasMore] = useState(true);

  const pageRef = useRef(0);
  const loadingRef = useRef(false);
  const abortRef = useRef(null);

  const loadMore = useCallback(async () => {
    if (loadingRef.current || !hasMore) return;
    loadingRef.current = true;
    setLoading(true);
    setError(null);

    if (abortRef.current) abortRef.current.abort();
    const controller = new AbortController();
    abortRef.current = controller;

    try {
      const nextPage = pageRef.current + 1;
      const batch = await fetchPage(nextPage, pageSize, controller.signal);
      pageRef.current = nextPage;
      setItems((prev) => [...prev, ...batch]);
      setHasMore(batch.length === pageSize);
    } catch (err) {
      if (err.name !== 'AbortError') setError(err);
    } finally {
      loadingRef.current = false;
      setLoading(false);
    }
  }, [fetchPage, pageSize, hasMore]);

  useEffect(() => {
    return () => {
      if (abortRef.current) abortRef.current.abort();
    };
  }, []);

  return { items, loading, error, hasMore, loadMore };
}
3 files · javascript Explain with highlit

This snippet shows the two halves of a real infinite-scroll list in React: a data hook that owns pagination state and network calls, and a sentinel hook that fires a callback when a DOM element scrolls into view. Keeping these concerns separate means the fetching logic can be tested and reused independently of how loading is triggered.

In usePaginatedFetch hook, pagination is modeled as an accumulating list plus a cursor. Each call to loadMore bumps the internal pageRef and appends the freshly fetched page onto items rather than replacing it, which is what makes the list grow instead of paging. The hook tracks loading, error, and hasMore so consumers can render spinners, retry buttons, and a natural stop condition. An AbortController is created per request and aborted on cleanup or when a new request supersedes the old one, avoiding the classic race where a slow earlier page resolves after a later one and clobbers state. The loadingRef guard is important: without it, several observer callbacks could fire in quick succession and launch duplicate requests for the same page.

In useOnScreen hook, an IntersectionObserver watches a single sentinel node. The hook accepts a ref and an onIntersect callback and re-subscribes whenever the node or options change. Storing the callback in savedCallback via a ref means the observer does not need to be torn down every time the parent passes a new inline function, which prevents needless observer churn on each render. The rootMargin option lets loading begin slightly before the sentinel is actually visible, giving the fetch a head start so scrolling feels seamless.

In FeedList component, the pieces come together: the hook supplies items and loadMore, and a sentinelRef is attached to an empty div at the bottom of the list. useOnScreen only calls loadMore when hasMore is true and no request is in flight, so the observer stays passive once the feed is exhausted. This pattern is preferable to scroll-event listeners because IntersectionObserver is asynchronous and off the main thread, avoiding jank. The main trade-off is that the sentinel must remain in the layout for the observer to detect it, and empty or short lists need a fallback so loadMore can still be reached.


Related snips

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"

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
javascript
import { Application } from "@hotwired/stimulus"
import FormSubmitController from "./controllers/form_submit_controller"

const application = Application.start()
application.debug = false

Disable submit button while Turbo form is submitting

rails hotwire stimulus
by codesnips 3 tabs
javascript
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static values = {
    url: String,
    delay: { type: Number, default: 800 },

Stimulus: autosave draft with Turbo-friendly requests

rails stimulus hotwire
by codesnips 3 tabs

Share this code

Here's the card — post it anywhere.

Infinite Scroll in React with IntersectionObserver and a Paginated Fetch Hook — share card
Link copied