<AnilPal

🖋️>

Infinite scrolling in React

hero image for article
🗓️ 29 Feb 2024⏳ 5 mins read

All of us casually surfing through the internet, scrolling through our Instagram feeds, binging Youtube recommendation videos, never even realize that we use infinite scrolling so intuitively on our phones and laptops every single day. It's the ease of its use that makes us never question - how is infinite scrolling actually implemented? Until we actually have to implement it somewhere. It might just take us one thumb to use infinite scrolling, but there is quite a bit of work that goes into implementing it. So in this article I'll show you two of ways to implement infinite scrolling. Let's begin!!

Let's try to make an app called ColorFool which generates tiles of random colors after a small delay of 500ms as we scroll to the bottom. Here is a glimpse of it -

NOTE - The working code is available in this repository.

For simplicity’s sake, let’s just use create-react-app to bootstrap the boilerplate code. Run the following command to create a new react app —

npx create-react-app

Using the onScroll event-handler

The first thing that comes to mind is of-course, using the onScroll event to implement infinite scrolling. It’s simple to implement and easy to understand. Here is the code for it —

App.jsx
import OnScrollContainer from "./OnScrollContainer";

const App = () => {
  return (
    <div className="flex p-8 w-full h-screen">
      <OnScrollContainer />
    </div>
  );
};

export default App;
OnScrollContainer.jsx
import { useState, useMemo, useRef } from "react";
import debounce from "lodash.debounce";
import { getRandomCells } from "./utils";

const OnScrollContainer = () => {
  const containerRef = useRef();
  const [onScrollCells, setOnScrollCells] = useState(getRandomCells(10));
  // OnScroll callback
  const onScrollCellsUpdate = (scrollEvent) => {
    const { scrollHeight, offsetHeight, scrollTop } = scrollEvent.target;
    if (scrollHeight === scrollTop + offsetHeight) {
      setTimeout(() => {
        setOnScrollCells((onScrollCells) => [
          ...onScrollCells,
          ...getRandomCells(10, onScrollCells.length),
        ]);

        containerRef.current.scrollTo({
          top: containerRef.current.scrollTop + 200,
          behavior: "smooth",
        });
      }, 500);
    }
  };

  const onScrollCellsUpdateDebounced = useMemo(
    () => debounce(onScrollCellsUpdate, 100),
    []
  );
  return (
    <div className="grow bg-white h-full p-4 mx-8 rounded drop-shadow-2xl">
      <h1 className="text-2xl text-center">{:bash}</h1>
      <div
        ref={containerRef}
        className="mt-8 w-full h-5/6 overflow-y-auto rounded"
        onScroll={onScrollCellsUpdateDebounced}
      >
        {onScrollCells}
      </div>
    </div>
  );
};

export default OnScrollContainer;
utils.js
export const getRandomColor = () =>
  `rgb(${Math.floor(Math.random() * 255)}, ${Math.floor(
    Math.random() * 255
  )}, ${Math.floor(Math.random() * 255)})`;

// Get `cellsCount` number of randomly colored cells
export const getRandomCells = (cellsCount, preset = 0) => {
  const cells = [];
  for (let i = 0; i < cellsCount; i++) {
    const bgColor = getRandomColor();
    cells.push(
      <div
        key={`${bgColor}-${preset + i}`}
        className="w-full py-8 text-center my-2 rounded"
        style={{
          backgroundColor: bgColor,
        }}
      >
        {bgColor}
      </div>
    );
  }
  return cells;
};

Let’s dissect it a little and understand what's going on inside the code —

  1. In the utils.js file we have defined two utility functions — first is to generate a random rgb color, and second function is to generate cellsCount number of cells with a randomly generated background color.
  2. Second we have defined a OnScrollContainer function component which have the randomly generated cells stored in a onScrollCells state.
  3. Then we have defined a onScrollCellsUpdateDebounced callback to be passed as the onScroll event handler. Here we are using the debounce function from lodash.debounce library to optimize the number of onClick event-handler triggers.
  4. The actual event-handler onScrollCellsUpdate is pretty straight-forward. We are executing a setTimeout with 500ms when we scroll till bottom of the container.

Pretty easy, right!! Now let’s see if can do something better.

Using the IntersectionObserver API

IntersectionObserver is a newer web API available in most of the prominent browsers. IntersectionObserver API solves the classic problem of detecting visibility of an element on screen. Find more information about IntersectionObserver (here)[https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API]. Let’s jump in the code —

App.jsx
import InterSectionObserverContainer from "./IntersectionObserverContainer";

const App = () => {
  return (
    <div className="flex p-8 w-full h-screen">
      <InterSectionObserverContainer />
    </div>
  );
};

export default App;
IntersectionObserverContainer.jsx
import { useState, useRef } from "react";
import { getRandomCells } from "./utils";
import useIOInfiniteScroll from "./useIOInfiniteScroll";

const InterSectionObserverContainer = () => {
  const loaderRef = useRef();
  const containerRef = useRef();
  const [ioCells, setIOCells] = useState(getRandomCells(10));

  // Hook enclosing the Intersection observer functionality
  useIOInfiniteScroll(loaderRef, () => {
    setTimeout(() => {
      setIOCells((ioCells) => [
        ...ioCells,
        ...getRandomCells(10, ioCells.length),
      ]);
      
      containerRef.current.scrollTo({
        top: containerRef.current.scrollTop + 200,
        behavior: "smooth",
      });
    }, 500);
  });

  return (
    <div className="relative grow bg-white h-full p-4 mx-8 rounded drop-shadow-2xl">
      <h1 className="text-2xl text-center">Intersection observer</h1>
      <div
        ref={containerRef}
        className="mt-8 w-full h-5/6 overflow-y-auto rounded"
      >
        {ioCells}
        <div className="invisible" style={{ height: "1px" }} ref={loaderRef} />
      </div>
    </div>
  );
};

export default InterSectionObserverContainer;
useIOInfiniteScroll.js
import { useEffect } from "react";

const useIOInfiniteScroll = (loaderRef, intersectionCallback) => {
  useEffect(() => {
    const IO = new IntersectionObserver(
      (entries) => {
        const [loaderEntry] = entries;
        if (loaderEntry.isIntersecting) intersectionCallback();
      },
      {
        root: null,
        rootMargin: "0px",
        threshold: 0,
      }
    );

    if (loaderRef.current) IO.observe(loaderRef.current);

    return () => {
      if (loaderRef.current) IO.unobserve(loaderRef.current);
    };
  }, [loaderRef]);
};

export useIOInfiniteScroll;

Let’s dissect this too and understand what we are doing in code —

  1. The utils.js and storing the cells in state is same as the onScroll example. Just the name of the cells’ state is ioCells.
  2. Next we have created a custom react hook useIOInfiniteScroll which abstracts out the implementation of IntersectionObserver. This hook accepts two parameters, first is the ref to a loader-indicator element and second is the callback that needs to be executed when scrolled till bottom.
  3. Now the idea here is to observe the loader-indicator element at the bottom of the scrollable container using IntersectionObserver. As soon as the loader-indicator element is visible on the screen, IntersectionObserver will trigger the callback which we passed to it as the second parameter. This callback will have the logic to append more colored slabs to the container.
  4. We should use the IntersectionObserver in a useEffect hook. This will allow us to return a callback from the useEffect hook to unobserve the loading-element when the component unmounts.

That’s it!! But how is this approach better than the onScroll approach? Let’s find out.

Final output combined

Both the onScroll and IntersectionObserver feels pretty much the same while using but there are some very fundamental differences between both of these approaches and there are some reasons of why should one approach be favoured over the other.

Quick comparison

So here is my personal opinionated view of the differences between both of these approaches —

onScroll

  1. Since this is just another DOM event-handler, it’s easy to use and more intuitive as we are very much familiar with DOM events.
  2. Scroll event fires up a lot of times in even in just one small scroll, hence the event-handler might end up blocking the main thread. In order to solve this problem we have to control the event-handler calls by using debounce, which is an extra effort.
  3. We also have to keep in mind the event-bubbling/capturing phases too while implementing any event-handler, and so is the case with scroll event too.

IntersectionObserver

  1. IntersectionObserver is a newer API with wide support amongst all major browsers.
  2. IntersectionObserver solves the problem of element visibility on screen in general, hence its applications spread beyond the use case of infinite-scrolling.
  3. IntersectionObserver does not obliterate the main thread with a lot of callback executions on main thread unlike onScroll approach. It just executes the callback on main thread only when the observability conditions are met for a target element.
  4. We also don’t need to go through the hassle of taking care of event bubbling/capturing phases and event debouncing/throttling when using IntersectionObserver.

So from this quick comparison above, it seems like IntersectionObserver is generally the way forward now for not just infinite-scrolling but a lot more other use-cases of element visibility on screen.

© 2024 Anil Pal. All rights reserved.