Skip to content

Instantly share code, notes, and snippets.

@carefree-ladka
Created June 30, 2026 08:21
Show Gist options
  • Select an option

  • Save carefree-ladka/e564559e6dd29a2a9c15e76b2624c1ab to your computer and use it in GitHub Desktop.

Select an option

Save carefree-ladka/e564559e6dd29a2a9c15e76b2624c1ab to your computer and use it in GitHub Desktop.
Infinite Scroll List — Code Walkthrough

Infinite Scroll List — Code Walkthrough

This document explains, step by step, how the App component implements an infinite scrolling list using React hooks and the Intersection Observer API.


1. Overview

The component renders a list of text items (Text 1, Text 2, ...) that loads automatically in pages of 10, up to a maximum of 10 pages (100 items total), as the user scrolls down. A loader element at the bottom of the list is watched by an IntersectionObserver; when it becomes visible, the next page is fetched.


2. Constants

const PER_PAGE = 10;
const MAX_PAGES = 10;
  • PER_PAGE — number of items fetched per "page" (batch).
  • MAX_PAGES — total number of pages allowed before scrolling stops loading new data (so the list caps at 100 items).

3. State and Refs

const [items, setItems] = useState([]);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);

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

State

  • items — the accumulated array of loaded text strings, rendered in the UI.
  • loading — drives the "loading..." text shown to the user.
  • hasMore — whether there are more pages left to fetch; once false, fetching stops permanently.

Refs

Refs are used here instead of state because their values need to be read synchronously and immediately inside fetchItems, without waiting for a re-render (state updates are asynchronous and could cause race conditions during fast scrolling).

  • pageRef — tracks the current page index outside of React's render cycle.
  • loadingRef — acts as a synchronous "lock" to prevent the same fetch from firing multiple times concurrently (e.g., if the observer fires repeatedly before setLoading(true) has actually re-rendered).
  • loaderRef — a DOM reference attached to the invisible "sentinel" <div> at the bottom of the list, which the IntersectionObserver watches.

4. The fetchItems Function

const fetchItems = useCallback(async () => {
  if (loadingRef.current || !hasMore) return;

  loadingRef.current = true;
  setLoading(true);

  const page = pageRef.current;

  const newItems = await new Promise((resolve) => {
    setTimeout(
      () =>
        resolve(
          Array.from(
            { length: PER_PAGE },
            (_, i) => `Text ${page * PER_PAGE + i + 1}`
          )
        ),
      300
    );
  });

  setItems((prev) => [...prev, ...newItems]);
  pageRef.current = page + 1;

  if (pageRef.current >= MAX_PAGES) {
    setHasMore(false);
  }

  loadingRef.current = false;
  setLoading(false);
}, [hasMore]);

Step by step:

  1. Guard clause: if (loadingRef.current || !hasMore) return; Prevents fetching if a request is already in progress, or if there's nothing left to load. This is the key mechanism that stops duplicate/overlapping fetches.

  2. Lock and loading UI: loadingRef.current = true; setLoading(true); Immediately locks further calls (synchronously, via the ref) and updates the UI state to show "loading...".

  3. Capture current page: const page = pageRef.current; Reads the current page number into a local variable so it doesn't shift mid-fetch.

  4. Simulated network request:

    const newItems = await new Promise((resolve) => {
      setTimeout(() => resolve(...), 300);
    });

    This mimics an API call with a 300ms delay. It generates an array of 10 strings like Text 1 through Text 10, where the numbering is calculated from page * PER_PAGE + i + 1 so each page continues numbering from where the last left off.

  5. Append results: setItems((prev) => [...prev, ...newItems]); Adds the newly "fetched" items to the existing list using the functional update form (safe against stale closures).

  6. Advance the page counter: pageRef.current = page + 1; Increments the page ref for the next fetch.

  7. Check for end of data:

    if (pageRef.current >= MAX_PAGES) {
      setHasMore(false);
    }

    Once the max number of pages has been reached, hasMore is set to false, which will block any future fetch attempts via the guard clause.

  8. Unlock: loadingRef.current = false; setLoading(false); Releases the lock and turns off the loading indicator.

Why useCallback? Wrapping fetchItems in useCallback with [hasMore] as a dependency means a new function is only created when hasMore changes — this keeps the function reference stable for the IntersectionObserver effect below, avoiding unnecessary observer re-creation.


5. Initial Load Effect

useEffect(() => {
  fetchItems();
  // eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

Runs once when the component first mounts, triggering the first page load immediately (so the user doesn't see an empty screen before scrolling). The empty dependency array [] ensures it only runs on mount; the eslint-disable comment suppresses the warning about fetchItems not being listed as a dependency (intentional, since we only want this on mount).


6. Intersection Observer Effect

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

  const observer = new IntersectionObserver(
    (entries) => {
      if (entries[0].isIntersecting) {
        fetchItems();
      }
    },
    {
      threshold: 0.5,
      rootMargin: "300px",
    }
  );

  observer.observe(node);

  return () => {
    observer.unobserve(node);
    observer.disconnect();
  };
}, [fetchItems]);

This is the core of the infinite-scroll behavior:

  1. Get the sentinel node: const node = loaderRef.current; — the DOM element at the bottom of the list (the <div ref={loaderRef}>).
  2. Create the observer: new IntersectionObserver(callback, options)
    • The callback fires whenever the sentinel's visibility relative to the viewport changes.
    • entries[0].isIntersecting checks if the sentinel is currently visible.
    • If visible, fetchItems() is called to load the next page.
  3. Options:
    • threshold: 0.5 — the callback fires once at least 50% of the sentinel is visible.
    • rootMargin: "300px" — expands the "viewport" boundary by 300px, so loading is triggered before the user actually reaches the very bottom (preloading for a smoother experience).
  4. Start observing: observer.observe(node);
  5. Cleanup: When the effect re-runs or the component unmounts, the observer stops watching the node and is disconnected, preventing memory leaks.

This effect re-runs whenever fetchItems changes (i.e., when hasMore changes), ensuring the observer's callback always has access to the latest, non-stale version of fetchItems.


7. Render

return (
  <div className="App">
    {items.map((item, idx) => (
      <p key={idx} style={{ fontSize: "25px", padding: "1rem" }}>
        {item}
      </p>
    ))}
    <div ref={loaderRef}>
      {loading && "loading..."}
      {!hasMore && "No more items"}
    </div>
  </div>
);
  1. List rendering: Each item in items is rendered as a <p> tag with a generated index key. (Note: using array index as key is acceptable here since items are only ever appended, never reordered or removed.)
  2. Sentinel/status element: The <div ref={loaderRef}> serves double duty:
    • It's the element observed by the IntersectionObserver to trigger more fetches.
    • It also displays status text: "loading..." while a fetch is in progress, or "No more items" once hasMore becomes false.

8. Summary — How It All Fits Together

Step What Happens
1 Component mounts → fetchItems() runs once to load page 0
2 IntersectionObserver starts watching the bottom sentinel <div>
3 User scrolls down → sentinel enters the viewport (or comes within 300px of it)
4 Observer callback fires → calls fetchItems()
5 fetchItems checks the lock (loadingRef) and hasMore, then fetches the next page
6 New items are appended to items, page counter increments
7 Repeat steps 3–6 until pageRef.current >= MAX_PAGES
8 hasMore becomes false → guard clause blocks further fetches → "No more items" is shown

This pattern — refs for synchronous guards, IntersectionObserver for scroll detection, and useCallback for stable function identity — is a common, performant approach to implementing infinite scroll without relying on scroll event listeners (which are less efficient and harder to debounce correctly).

@carefree-ladka

carefree-ladka commented Jun 30, 2026

Copy link
Copy Markdown
Author

Infinite Scroll List - Codesandbox

import "./styles.css";
import { useState, useEffect, useRef, useCallback } from "react";

const PER_PAGE = 10;
const MAX_PAGES = 10;

export default function App() {
  const [items, setItems] = useState([]);
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);

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

  const fetchItems = useCallback(async () => {
    if (loadingRef.current || !hasMore) return;

    loadingRef.current = true;
    setLoading(true);

    const page = pageRef.current;

    const newItems = await new Promise((resolve) => {
      setTimeout(
        () =>
          resolve(
            Array.from(
              { length: PER_PAGE },
              (_, i) => `Text ${page * PER_PAGE + i + 1}`
            )
          ),
        300
      );
    });

    setItems((prev) => [...prev, ...newItems]);
    pageRef.current = page + 1;

    if (pageRef.current >= MAX_PAGES) {
      setHasMore(false);
    }

    loadingRef.current = false;
    setLoading(false);
  }, [hasMore]);

  // initial load
  useEffect(() => {
    fetchItems();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

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

    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting) {
          fetchItems();
        }
      },
      {
        threshold: 0.5,
        rootMargin: "300px",
      }
    );

    observer.observe(node);

    return () => {
      observer.unobserve(node);
      observer.disconnect();
    };
  }, [fetchItems]);

  return (
    <div className="App">
      {items.map((item, idx) => (
        <p key={idx} style={{ fontSize: "25px", padding: "1rem" }}>
          {item}
        </p>
      ))}
      <div ref={loaderRef}>
        {loading && "loading..."}
        {!hasMore && "No more items"}
      </div>
    </div>
  );
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment