An endless scroll of parchment cascading across a sunlit room while a gray-blue cat watches from a windowsill.

Scroll Without End: Effortless Pagination with react-infinite-scroll-component

The Gray Cat
The Gray Cat
0 views

Infinite scrolling has quietly become one of the default ways people consume content on the web. Social feeds, search results, product listings, comment threads, chat histories: instead of pagination buttons, the next batch of items just appears as you reach the bottom. Building that behavior from scratch means juggling scroll positions, debounced listeners, thresholds, and a guard so you don't fire ten requests at once. react-infinite-scroll-component wraps all of that up into a single component (and now a hook) that watches the edge of your list and calls you back when it's time to load more.

The deal is simple: you bring the data fetching and the state, and the library tells you when to fetch. It doesn't care where your data comes from, whether you use TanStack Query, SWR, plain fetch, or a websocket. With roughly 1.29 million weekly downloads it's one of the most-used infinite-scroll solutions in the React ecosystem, and the recent version 7 rewrite has made it leaner and more modern than ever.

Why This Library Earns Its Keep

The pitch is small but pointed: it does one thing, and the v7 line does it well.

  • IntersectionObserver under the hood. As of v7 the library uses a sentinel element watched by IntersectionObserver instead of throttled scroll listeners. That means less scripting overhead and smoother scrolling.
  • Zero runtime dependencies. The whole thing weighs about 5.77 kB minified, around 2.27 kB gzipped, with no transitive packages dragged into your bundle.
  • TypeScript-first with bundled types. No separate @types package; the typings ship in the box.
  • Two APIs. A drop-in InfiniteScroll component for the common case, and a useInfiniteScroll hook (new in 7.2.0) when you want full control over the markup.
  • Multiple scroll modes. Window scroll, fixed-height containers, custom scrollable parents, plus an inverse mode for chat-style "load older messages" lists.
  • Pull-to-refresh. Built-in touch and mouse pull-down-to-refresh for mobile-friendly feeds.
  • React 17, 18, and 19 support. The v7 line bumped the peer floor to React 17 and added clean support for the latest releases.

What it deliberately does not do is virtualize. Every item you load stays in the DOM. For typical feeds of dozens or a few hundred items that's perfectly fine, and we'll talk about where the line is later.

Getting It Into Your Project

Installation is a one-liner with your package manager of choice:

npm install react-infinite-scroll-component
yarn add react-infinite-scroll-component
pnpm add react-infinite-scroll-component

The only peer dependencies are react and react-dom at version 17 or higher, so React 17, 18, and 19 are all covered. Because the component touches window and IntersectionObserver, it is client-side only, render it inside a client component and guard against server-side rendering.

The Five-Prop Happy Path

The classic use case is a window-scrolling feed. You keep your items in state, track whether there's more to load, and append the next page inside the next callback.

import { useState } from 'react';
import InfiniteScroll from 'react-infinite-scroll-component';

type Item = { id: number; name: string };

function Feed() {
  const [items, setItems] = useState<Item[]>(initialItems);
  const [hasMore, setHasMore] = useState(true);

  const fetchMore = async () => {
    const next = await api.getItems({ offset: items.length });
    if (next.length === 0) {
      setHasMore(false);
      return;
    }
    setItems((prev) => [...prev, ...next]);
  };

  return (
    <InfiniteScroll
      dataLength={items.length}
      next={fetchMore}
      hasMore={hasMore}
      loader={<p>Loading...</p>}
      endMessage={<p style={{ textAlign: 'center' }}>All items loaded.</p>}
    >
      {items.map((item) => (
        <div key={item.id}>{item.name}</div>
      ))}
    </InfiniteScroll>
  );
}

Five props carry the whole interaction. dataLength is the current count of rendered items; next is the callback fired once when the sentinel scrolls into view; hasMore tells the component whether to keep watching; loader shows below the list while the next page loads; and the optional endMessage appears once everything is exhausted.

The single most important habit here is keeping dataLength in sync. It is the guard that re-arms the next fetch. When dataLength changes, the component knows new content arrived and rearms its observer for the following page. Forget to update it and you'll see the infamous "it only fires once" bug. Likewise, set hasMore to false when the source runs dry, otherwise next() keeps firing and the loader never disappears.

Picking Your Scroll Container

Not every list scrolls the whole window. The component supports three modes, and switching between them is a matter of which props you pass.

For an embedded list with its own scrollbar, hand it a height:

<InfiniteScroll
  dataLength={items.length}
  next={fetchMore}
  hasMore={hasMore}
  height={400}
  loader={<p>Loading...</p>}
>
  {items.map((item) => (
    <div key={item.id}>{item.name}</div>
  ))}
</InfiniteScroll>

If the scrollable element already exists in your layout, point at it with scrollableTarget instead of letting the component own the container:

<div id="scrollableDiv" style={{ height: 400, overflow: 'auto' }}>
  <InfiniteScroll
    dataLength={items.length}
    next={fetchMore}
    hasMore={hasMore}
    loader={<p>Loading...</p>}
    scrollableTarget="scrollableDiv"
  >
    {items.map((item) => (
      <div key={item.id}>{item.name}</div>
    ))}
  </InfiniteScroll>
</div>

scrollableTarget accepts either an element id string, as above, or an actual HTMLElement reference (handy with useRef), which is new in v7. Omit both height and scrollableTarget and you get plain window scrolling, ideal for social feeds, blogs, and listing pages.

One more knob worth knowing is scrollThreshold. It defaults to 0.8, meaning the fetch fires when the user has scrolled through 80% of the content. You can pass a different fraction, or a pixel string like "200px" to trigger when the user is a fixed distance from the end.

Chat-Style Lists That Grow Upward

Messaging apps invert the usual direction: the newest message sits at the bottom and scrolling up loads older history. The inverse prop flips the sentinel to the top edge so this just works.

<div
  id="chatBox"
  style={{
    height: 500,
    overflow: 'auto',
    display: 'flex',
    flexDirection: 'column-reverse',
  }}
>
  <InfiniteScroll
    dataLength={messages.length}
    next={loadOlderMessages}
    hasMore={hasMore}
    loader={<p>Loading older messages...</p>}
    inverse={true}
    scrollableTarget="chatBox"
    style={{ display: 'flex', flexDirection: 'column-reverse' }}
  >
    {messages.map((msg) => (
      <div key={msg.id}>{msg.text}</div>
    ))}
  </InfiniteScroll>
</div>

The column-reverse flex direction on both the container and the inner element keeps newest-at-the-bottom rendering intact, while inverse moves the load trigger to where older content lives. Pair this with a scrollableTarget so the fixed-height chat box owns the scroll.

Rolling Your Own UI With the Hook

Sometimes the component's wrapper markup gets in the way of a precise layout, a grid, a table, or a tightly styled list. Version 7.2.0 added useInfiniteScroll, a headless hook that hands back a ref and a loading flag and lets you render everything yourself.

import { useState } from 'react';
import { useInfiniteScroll } from 'react-infinite-scroll-component';

function CustomFeed() {
  const [items, setItems] = useState<Item[]>(initialItems);
  const [hasMore, setHasMore] = useState(true);

  const { sentinelRef, isLoading } = useInfiniteScroll({
    next: async () => {
      const more = await api.getItems({ offset: items.length });
      if (more.length === 0) {
        setHasMore(false);
        return;
      }
      setItems((prev) => [...prev, ...more]);
    },
    hasMore,
    dataLength: items.length,
  });

  return (
    <ul>
      {items.map((item) => (
        <li key={item.id}>{item.name}</li>
      ))}
      <li ref={sentinelRef} aria-hidden="true" />
      {isLoading && <li>Loading...</li>}
      {!hasMore && <li>All items loaded.</li>}
    </ul>
  );
}

You attach sentinelRef to an element placed at the end of your list, and the hook watches that element instead of injecting its own. The returned isLoading lets you render your own loading indicator. The hook accepts the same core options as the component, dataLength, next, hasMore, plus scrollThreshold, scrollableTarget, and inverse, so the mental model carries over. Reach for the hook when you need bespoke markup, and stick with the component when the defaults suit you.

Wiring It to a Data Layer

The library pairs naturally with paginated data tools because it only cares about three things you already have. With TanStack Query's useInfiniteQuery, the mapping is almost mechanical:

const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
  useInfiniteQuery({ /* ... */ });

const posts = data?.pages.flat() ?? [];

<InfiniteScroll
  dataLength={posts.length}
  next={fetchNextPage}
  hasMore={!!hasNextPage}
  loader={isFetchingNextPage ? <p>Loading...</p> : null}
>
  {posts.map((p) => (
    <article key={p.id}>{p.title}</article>
  ))}
</InfiniteScroll>;

fetchNextPage becomes next, hasNextPage becomes hasMore, and the flattened page count feeds dataLength. With SWR's useSWRInfinite, you'd wire next={() => setSize(size + 1)}, set dataLength from your flattened posts, and derive hasMore from whether the last page came back full. In a Next.js App Router app, fetch the first page in a server component and hand it to a 'use client' child that owns the useState and the InfiniteScroll, since the scroll detection is browser-only.

A Note on Accessibility

Since 7.2.1 the component forwards semantic attributes to its container, so screen-reader users get a list that announces itself properly:

<InfiniteScroll
  role="list"
  aria-label="Search results"
  dataLength={items.length}
  next={fetchMore}
  hasMore={hasMore}
  loader={<p>Loading...</p>}
>
  {items.map((item) => (
    <div role="listitem" key={item.id}>
      {item.name}
    </div>
  ))}
</InfiniteScroll>

You can pass role, tabIndex, id, and any aria-* attribute, and they land on the scroll container instead of being dropped.

Where the Line Is

The one limitation worth being honest about: this library does not virtualize. Every loaded item stays mounted in the DOM. For feeds of dozens or a few hundred rows that's a non-issue, and the simplicity is the whole point. But if you're rendering thousands of rows, the node count itself becomes the bottleneck, and that's where windowing tools like react-window or TanStack Virtual earn their place, often combined with a data layer like TanStack Query. Think of react-infinite-scroll-component as the low-friction choice for "load the next page when the user reaches the bottom," and reach for virtualization only when the DOM size, not the fetching, is what's slowing you down.

Wrapping Up

react-infinite-scroll-component nails the most common infinite-scroll need with a tiny, dependency-free package and an API you can learn in one sitting. Pass it your item count, a fetch callback, a hasMore flag, and a loader, and it handles the rest. The v7 rewrite modernized the internals with IntersectionObserver, trimmed the bundle to a couple of kilobytes, added the useInfiniteScroll hook for custom layouts, and brought React 19 into the fold. Keep dataLength honest, flip hasMore off when you're done, and you'll have a smooth endless feed in minutes, leaving virtualization for the day your lists actually grow into the thousands.