Infinite scroll in React with a real API example for web development | 200OK Solutions

Infinite Scroll in React with a Real API Example

Share this post on:

Infinite scroll in React loads the next batch of data as the user nears the end of a list, so there is no “Next page” button to click. You can build it three ways: a custom Intersection Observer hook, the react-infinite-scroll-component library, or TanStack Query’s useInfiniteQuery. This guide builds all three against a real API, compares them, and covers the SEO, accessibility and performance traps most tutorials skip.

Which infinite scroll method should you use?

MethodBest forExtra dependencyCaching and retriesEffort
Intersection Observer hookFull control and the smallest bundleNoneYou write itMedium
react-infinite-scroll-componentA quick setup on a simple listreact-infinite-scroll-componentYou write itLow
TanStack Query + Intersection ObserverProduction apps with server data@tanstack/
react-query
Built inMedium

As a rule of thumb, use the custom hook when you want zero dependencies, the library when you need a working list in ten minutes, and TanStack Query when the data comes from a real backend and you care about caching, retries and race conditions.

What is infinite scroll in React?

Infinite scroll is a loading pattern where new items are appended to a list automatically as the user scrolls. Under the hood, every implementation has the same three parts:

  • A trigger that detects when the user is close to the end of the list.
  • A fetch that requests the next page, using an offset (limit and skip) or a cursor.
  • An append that adds the new items to state without replacing the old ones.

The examples below use the free DummyJSON products API. A request like https://dummyjson.com/products?limit=10&skip=0 returns products, total, skip and limit, which is everything we need.

Set up the project

npm create vite@latest infinite-scroll-app -- --template react
cd infinite-scroll-app
npm install

Method 1: Infinite scroll with Intersection Observer (custom hook)

Scroll event listeners fire constantly and need throttling. The Intersection Observer API is cheaper: the browser tells you only when a target element enters the viewport. We place a tiny invisible “sentinel” element after the list and load more whenever it becomes visible.

First, a reusable hook:

// useInfiniteScroll.js
import { useEffect, useRef } from 'react';

export function useInfiniteScroll(onLoadMore, { enabled = true, rootMargin = '200px' } = {}) {
  const sentinelRef = useRef(null);
  const onLoadMoreRef = useRef(onLoadMore);
  onLoadMoreRef.current = onLoadMore; // always call the latest callback

  useEffect(() => {
    const node = sentinelRef.current;
    if (!enabled || !node) return;

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) onLoadMoreRef.current();
      },
      { rootMargin }
    );

    observer.observe(node);
    return () => observer.disconnect();
  }, [enabled, rootMargin]);

  return sentinelRef;
}

Then the component that uses it:

// ProductFeed.jsx
import { useCallback, useRef, useState } from 'react';
import { useInfiniteScroll } from './useInfiniteScroll';

const LIMIT = 10;

export default function ProductFeed() {
  const [products, setProducts] = useState([]);
  const [total, setTotal] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const fetching = useRef(false);

  const hasMore = total === null || products.length < total;

  const loadMore = useCallback(async () => {
    if (fetching.current) return; // blocks duplicate requests
    fetching.current = true;
    setLoading(true);
    setError(null);

    try {
      const res = await fetch(
        `https://dummyjson.com/products?limit=${LIMIT}&skip=${products.length}`
      );
      if (!res.ok) throw new Error(`Request failed with status ${res.status}`);
      const data = await res.json();
      setProducts((prev) => [...prev, ...data.products]);
      setTotal(data.total);
    } catch (err) {
      setError(err.message);
    } finally {
      fetching.current = false;
      setLoading(false);
    }
  }, [products.length]);

  const sentinelRef = useInfiniteScroll(loadMore, {
    enabled: hasMore && !loading && !error,
  });

  return (
    <section>
      <h1>Products</h1>
      <ul>
        {products.map((product) => (
          <li key={product.id}>
            <h2>{product.title}</h2>
            <p>{product.description}</p>
            <strong>${product.price}</strong>
          </li>
        ))}
      </ul>

      {loading && <p role='status'>Loading more products…</p>}
      {error && <button onClick={loadMore}>Retry</button>}
      {hasMore && <div ref={sentinelRef} aria-hidden='true' style={{ height: 1 }} />}
      {!hasMore && <p>You’ve reached the end.</p>}
    </section>
  );
}

How it works

  • The sentinel div sits below the list. When it comes within 200px of the viewport (rootMargin), the observer calls loadMore.
  • The enabled flag turns the observer off while a request is running. When loading finishes, the observer is recreated and fires again if the sentinel is still visible, so short first pages keep loading until the screen is full.
  • The fetching ref blocks duplicate requests, including the double effect run you get in React StrictMode during development.
  • skip comes from products.length, so the offset can never drift out of sync with the list.

Method 2: Infinite scroll with react-infinite-scroll-component

react-infinite-scroll-component wraps the scroll detection for you. It is the fastest route to a working list.

npm install react-infinite-scroll-component
// ProductList.jsx
import { useEffect, useRef, useState } from 'react';
import InfiniteScroll from 'react-infinite-scroll-component';

const LIMIT = 10;

export default function ProductList() {
  const [products, setProducts] = useState([]);
  const [total, setTotal] = useState(null);
  const fetching = useRef(false);

  const fetchMore = async () => {
    if (fetching.current) return;
    fetching.current = true;
    try {
      const res = await fetch(
        `https://dummyjson.com/products?limit=${LIMIT}&skip=${products.length}`
      );
      const data = await res.json();
      setProducts((prev) => [...prev, ...data.products]);
      setTotal(data.total);
    } catch (err) {
      console.error('Could not load products', err);
    } finally {
      fetching.current = false;
    }
  };

  useEffect(() => {
    fetchMore();
  }, []);

  return (
    <InfiniteScroll
      dataLength={products.length}
      next={fetchMore}
      hasMore={total === null || products.length < total}
      loader={<p role='status'>Loading more products…</p>}
      endMessage={<p>You’ve reached the end.</p>}
    >
      {products.map((product) => (
        <article key={product.id}>
          <h2>{product.title}</h2>
          <p>{product.description}</p>
          <strong>${product.price}</strong>
        </article>
      ))}
    </InfiniteScroll>
  );
}

The props that matter:

  • dataLength tells the component how many items are rendered, so it knows when new data has arrived.
  • next is called when the user scrolls near the bottom.
  • hasMore stops further calls once everything is loaded.
  • scrollThreshold controls how early next fires, and scrollableTarget lets you scroll inside a fixed-height container instead of the window.

The trade-off: the library only detects scrolling. Caching, retries and race conditions are still your job, which is where Method 3 helps.

Method 3: Infinite scroll with TanStack Query (useInfiniteQuery)

Once data comes from a real backend, hand-written fetch logic gets fragile. TanStack Query’s infinite queries track pages, hasNextPage, loading state, caching, retries and request deduplication for you. We keep the Intersection Observer hook from Method 1 as the trigger.

npm install @tanstack/react-query

Wrap your app once:

// main.jsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const queryClient = new QueryClient();

ReactDOM.createRoot(document.getElementById('root')).render(
  <QueryClientProvider client={queryClient}>
    <App />
  </QueryClientProvider>
);

Then build the feed:

// QueryProductFeed.jsx
import { useInfiniteQuery } from '@tanstack/react-query';
import { useInfiniteScroll } from './useInfiniteScroll';

const LIMIT = 10;

async function fetchProducts({ pageParam }) {
  const res = await fetch(
    `https://dummyjson.com/products?limit=${LIMIT}&skip=${pageParam}`
  );
  if (!res.ok) throw new Error('Failed to fetch products');
  return res.json();
}

export default function QueryProductFeed() {
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage, status, error } =
    useInfiniteQuery({
      queryKey: ['products'],
      queryFn: fetchProducts,
      initialPageParam: 0,
      getNextPageParam: (lastPage) => {
        const next = lastPage.skip + lastPage.limit;
        return next < lastPage.total ? next : undefined; // undefined = no more pages
      },
    });

  const sentinelRef = useInfiniteScroll(fetchNextPage, {
    enabled: Boolean(hasNextPage) && !isFetchingNextPage,
  });

  if (status === 'pending') return <p role='status'>Loading…</p>;
  if (status === 'error') return <p>Something went wrong: {error.message}</p>;

  const products = data.pages.flatMap((page) => page.products);

  return (
    <section>
      <ul>
        {products.map((product) => (
          <li key={product.id}>{product.title}</li>
        ))}
      </ul>
      {isFetchingNextPage && <p role='status'>Loading more…</p>}
      {hasNextPage && <div ref={sentinelRef} aria-hidden='true' style={{ height: 1 }} />}
    </section>
  );
}

What you get for free: returning undefined from getNextPageParam ends the feed, data.pages holds every page loaded so far, a failed request retries automatically, and navigating away and back shows cached pages instantly instead of refetching from page one.

Infinite scroll for large lists: add virtualization

Appending items forever means the DOM grows forever. After a few thousand rows, scrolling slows down and memory climbs. The fix is windowing (virtualization), which renders only the rows currently on screen. Libraries such as react-window and TanStack Virtual do this, and react-window-infinite-loader pairs windowing with page loading. If your list can realistically pass a few hundred items, plan for virtualization from the start.

Infinite scroll vs pagination vs load more button

PatternBest forWatch out for
Infinite scrollFeeds, discovery browsing, image galleriesUnreachable footer, no shareable position, harder to index
PaginationSearch results, catalogues, anything users bookmarkMore clicks and page loads
Load more buttonA middle ground for most product listsOne extra click per batch

A “Load more” button is often the best default. Users stay in control, the footer stays reachable, and keyboard and screen reader users are not fighting an endless list.

SEO and accessibility checklist

  • Make content crawlable. Search engines do not scroll. Back your feed with real paginated URLs (for example /products?page=2) linked with ordinary <a href> tags, and update the URL with the History API as pages load.
  • Render the first page on the server. Frameworks such as Next.js can send page one as HTML so crawlers and slow devices see content immediately.
  • Keep the footer reachable. Use a Load more button, or stop auto-loading after a set number of pages.
  • Announce loading. Use role='status' or aria-live='polite' on the loader so screen readers hear that new items arrived.
  • Never steal focus. Append new items after the current focus position and keep keyboard order logical.
  • Respect prefers-reduced-motion. Skip fade-in and skeleton animations for users who ask for less motion.
  • Restore scroll position. When users return from a detail page, bring back the same items and position.
  • Handle errors and empty states. Always show a retry option and a clear end-of-list message.

Common infinite scroll mistakes in React

  1. Double fetching in StrictMode. In development, React runs effects twice. Guard requests with a ref (as above) or let TanStack Query deduplicate them.
  2. Stale closures. Reading state inside an async callback gives you old values. Use functional updates (setProducts(prev => ...)) and derive the offset from the current list.
  3. Missing or duplicate keys. Use a stable ID such as product.id, never the array index.
  4. No end condition. Compare loaded items to total, or stop when the API returns an empty page.
  5. Unbounded list growth. Virtualize or cap the list so memory stays flat.
  6. No error state. A silent failure leaves users staring at a spinner. Show the error and a Retry button.

Infinite scroll in React Native

React Native has this built in. FlatList calls onEndReached when the user scrolls within onEndReachedThreshold of the end of the list. Use the same pagination logic and the same guard against duplicate requests shown above.

FAQ

Q. How do I implement infinite scroll in React?

A. Keep the loaded items in state, place a sentinel element below the list, and use an Intersection Observer to call your fetch function when the sentinel becomes visible. Append the new items with a functional state update and stop when the API says there is no more data.

Q. Is Intersection Observer better than a scroll event listener?

A. For infinite scroll, yes. A scroll listener runs on every scroll event and usually needs throttling. Intersection Observer notifies you only when the target enters the viewport, which is simpler and lighter on the main thread.

Q. Is infinite scroll bad for SEO?

A. It can be, if content only loads when a user scrolls. Search engine crawlers do not scroll, so items beyond the first batch may never be discovered. Add paginated URLs with normal links behind the feed, and server-render the first page.

Q. What is the difference between react-infinite-scroll-component and TanStack Query?

A. They solve different problems. react-infinite-scroll-component detects scrolling and triggers a load. TanStack Query manages fetching, caching, retries and pagination state. Many production apps use a scroll trigger together with useInfiniteQuery.

Q. How do I handle thousands of items in an infinite list?

A. Use virtualization. Libraries like react-window or TanStack Virtual render only the visible rows, which keeps the DOM small and scrolling smooth however many items you have loaded.

Conclusion

For a small project, the custom Intersection Observer hook gives you control with no dependencies & quickest start, react-infinite-scroll-component works well. For real applications with server data, pair the observer with TanStack Query’s useInfiniteQuery, and add virtualization once lists grow long. Whichever you choose, give search engines paginated URLs and give users a way to reach the footer.

Building a React product where performance, data loading and user experience all matter? 200OK Solutions provides digital product development services, from frontend architecture and API integration to launch. Contact us to talk through your project, or read our series on what’s new in React 19 to keep your stack current.

Avatar photo

Piyush Solanki

PHP Tech Lead & Backend Architect

10+ years experience
UK market specialist
Global brands & SMEs
Full-stack expertise

Core Technologies

PHP 95%
MySQL 90%
WordPress 92%
AWS 88%
  • Backend: PHP, MySQL, CodeIgniter, Laravel
  • CMS: WordPress customization & plugin development
  • APIs: RESTful design, microservices architecture
  • Frontend: React, TypeScript, modern admin panels
  • Cloud: AWS S3, Linux deployments
  • Integrations: Stripe, SMS/OTP gateways
  • Finance: Secure payment systems & compliance
  • Hospitality: Booking & reservation systems
  • Retail: E-commerce platforms & inventory
  • Consulting: Custom business solutions
  • Food Services: Delivery & ordering systems
  • Modernizing legacy systems for scalability
  • Building secure, high-performance products
  • Mobile-first API development
  • Agile collaboration with cross-functional teams
  • Focus on operational efficiency & innovation

Piyush Solanki is a seasoned PHP Tech Lead with 10+ years of experience architecting and delivering scalable web and mobile backend solutions for global brands and fast-growing SMEs.

He specializes in PHP, MySQL, CodeIgniter, WordPress, and custom API development, helping businesses modernize legacy systems and launch secure, high-performance digital products.

He collaborates closely with mobile teams building Android & iOS apps, developing RESTful APIs, cloud integrations, and secure payment systems. With extensive experience in the UK market and across multiple sectors, Piyush Solanki is passionate about helping SMEs scale technology teams and accelerate innovation through backend excellence.

    Reach Out Us


    Your name

    Your email

    Subject

    Your message