TypeOfNaN

Creating a React Infinite Scroll Component

Nick Scialli
April 10, 2021

Infinite scrolling content is a common way to deal with large collections of data. It enables you to load only a smaller fraction of the data, save bandwidth, and avoid slow rendering that comes along with large lists.

In this post, we’re going to create an InfiniteScroll component!

Setting Boundaries

While an InfiniteScroll component might seem like a daunting task, it turns out the component itself doesn’t need to actually do all that much—we just have to be very clear about its responsibilities. Some existing solutions might have the component doing more things, but I think it’s best if the component specializes in the following tasks:

  • Detecting when the user has scrolled to the bottom of the page
  • Calling a function supplied by the parent when the bottom of the page is detected
  • Not calling that function redundantly (i.e., only once per bottom hit)
  • Not calling that function if the parent says we’re done (i.e., no more data is left)
  • Do an initial data load on mount (or not, if the user doesn’t want)

You might be wondering why the component knows nothing about pagination and how it’s going to render the data being supplied. I’m of the mindset that, to make this component as flexible as possible, it shouldn’t know anything about pagination (that would make a big assumption about how we fetch our data) and it also shouldn’t have any rendering logic for its children—this is only a helper component to facilitate the interaction we’re going for.

Defining the Component Interface

We can use our aforementioned list to define the interface for our component. I’ll do so using Typescript, but it can easily be done without the types.

type Props = {
  onBottomHit: () => void;
  isLoading: boolean;
  hasMoreData: boolean;
  loadOnMount: boolean;
};

const InfiniteScroll: React.FC<Props> = ({
  onBottomHit,
  isLoading,
  hasMoreData,
  children,
  loadOnMount,
}) => {};

Filling Out the Component

We can detect the bottom hit by adding a scroll handler to our window scroll event. We’ll also need to know where our scrollable content is. To understand where our content is, we can use a React ref! I’m going to put a lot of this together and explain what we’ve done after the code.

type Props = {
  onBottomHit: () => void;
  isLoading: boolean;
  hasMoreData: boolean;
  loadOnMount: boolean;
};

function isBottom(ref: React.RefObject<HTMLDivElement>) {
  if (!ref.current) {
    return false;
  }
  return ref.current.getBoundingClientRect().bottom <= window.innerHeight;
}

const InfiniteScroll: React.FC<Props> = ({
  onBottomHit,
  isLoading,
  hasMoreData,
  loadOnMount,
  children,
}) => {
  const [initialLoad, setInitialLoad] = useState(true);
  const contentRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (loadOnMount && initialLoad) {
      onBottomHit();
      setInitialLoad(false);
    }
  }, [onBottomHit, loadOnMount, initialLoad]);

  useEffect(() => {
    const onScroll = () => {
      if (!isLoading && hasMoreData && isBottom(contentRef)) {
        onBottomHit();
      }
    };
    document.addEventListener('scroll', onScroll);
    return () => document.removeEventListener('scroll', onScroll);
  }, [onBottomHit, isLoading, hasMoreData]);

  return <div ref={contentRef}>{children}</div>;
};

We have created an isBottom function that takes our React ref object as an argument. If the bottom of the div that ref is pointing to is less than or equal to our window’s innerHeight, we know that our scroll position is below that div and it’s time to load more data!

To attach this logic to our window’s scroll event, we use a useEffect hook. We make sure that we only execute the onBottomHit function when data’s not currently loading, when there’s still more data ,and when the bottom is actually hit.

We also make sure to clean up our effect by removing the scroll event from the window on unmount.

Finally, we handle an important edge case—when the component loads. It’s likely preferable that a component load is treated the same as a bottom hit. In other words, we want to load some initial data! To do so, we make sure to let the user specify this behavior and take action accordingly.

Let’s Smoke Test It

We can smoke test our InfiniteScroll component by creating a simple number list. It’ll show 100 numbers at a time and end at 1000 numbers. We can add a 300ms “loading” delay for effect.

const NUMBERS_PER_PAGE = 100;

function App() {
  const [numbers, setNumbers] = useState<number[]>([]);
  const [loading, setLoading] = useState(false);
  const [page, setPage] = useState(0);

  const hasMoreData = numbers.length < 1000;

  const loadMoreNumbers = () => {
    setPage((page) => page + 1);
    setLoading(true);
    setTimeout(() => {
      const newNumbers = new Array(NUMBERS_PER_PAGE)
        .fill(1)
        .map((_, i) => page * NUMBERS_PER_PAGE + i);
      setNumbers((nums) => [...nums, ...newNumbers]);
      setLoading(false);
    }, 300);
  };

  return (
    <InfiniteScroll
      hasMoreData={hasMoreData}
      isLoading={loading}
      onBottomHit={loadMoreNumbers}
      loadOnMount={true}
    >
      <ul>
        {numbers.map((n) => (
          <li key={n}>{n}</li>
        ))}
      </ul>
    </InfiniteScroll>
  );
}

And if we check this out in action, we can see it working great:

infinite scroll demo

If you'd like to support this blog by buying me a coffee I'd really appreciate it!

Nick Scialli

Nick Scialli is a senior UI engineer at Microsoft.

© 2024 Nick Scialli