Make infinite scrolling using JavaScript Intersection Observer API in Next

In this concise tutorial, we will demonstrate how to implement an infinite scroll feature using Next.js. Infinite scrolling is a powerful technique that dynamically loads content as users scroll down a page, enhancing user engagement. However, it's important to note that infinite scrolling may not be suitable for every website, and alternatives such as pagination or a "Load More" button should be considered depending on your specific use case.

Infinite scrolling, also known as endless scrolling, is a design approach commonly used on listing pages. It works by fetching and displaying additional content automatically as the user scrolls down the page, eliminating the need for traditional pagination, where content is divided into multiple pages. This approach provides a seamless browsing experience for users.

Now, let's dive into implementing infinite scrolling in Next.js, outlining the basic steps you need to follow:

Set Up Your Next.js Project:

Make sure you have a Next.js project in place. You can create one using the Next.js CLI or set it up manually.

Write below command in your terminal to create a next project and select yes for tailwind configuration.

npx create-next-app@latest infinite-scroll

Make the below changes in your index.jsx.

Here we will call our mock api and populate that data in a state which will be passed to our Content component for rendering list of ToDos. We will also keep track of page number in separate state which will be updated when we reach the end of page for currently loaded content.

// src/pages/index.tsx
import { Inter } from "next/font/google";
import { useCallback, useEffect, useState } from "react";
import Content from "@/components/Content";

const inter = Inter({ subsets: ["latin"] });

export default function Home() {
  const LIMIT = 25;
  const [page, setPage] = useState(1);
  const [todos, setTodos]: any = useState([]);
  const [hasMore, setHasMore] = useState(false);
  const [isLoading, setIsLoading] = useState(false);

  const fetchTodos: any = async (page: any) => {
    const response: any = await fetch(
      `https://jsonplaceholder.typicode.com/todos?_page=${page}&_limit=${LIMIT}`
    );
    return response.json();
  };

  useEffect(() => {
    (async () => {
      const newtodos = await fetchTodos(page);
      setTodos((prevtodos: any) => [...prevtodos, ...newtodos]);
      setHasMore(newtodos.length > 0);
      setIsLoading(false);
    })();
  }, [page]);

  const loadMore = useCallback(() => {
    setPage((page) => page + 1);
    setIsLoading(true);
  }, []);

  return (
    <div className="app">
      {todos.length > 0 && (
        <Content
          hasMore={hasMore}
          isLoading={isLoading}
          loadMore={loadMore}
          todos={todos}
        />
      )}
    </div>
  );
}

Below component will render list of todos. Here we will be using a custom useOnscreen hook which leverages Intersection Observer Web Api and identifies when we have reached the end of the page and need to update the page state by calling the loadMore function made using the useCallback function which updates the page state hence calling the list api with updated or next page data thus we achieve infinite scrolling by concatenating update list data in existing one and looping it while rendering a ToDo component. If you observe the below code carefully we have assigne measurRef to ToDo element which is rendered at end of currently loaded page so basically with this reference we are targeting the last ToDo element of our current page and checking if we have scrolled to that section using intersection observer api and thus calling the api again.

//src/components/Content.tsx
import useOnscreen from "@/hooks/useOnscreen";
import React, { Fragment, useEffect } from "react";
import ToDo from "./ToDo";

const Content = ({ hasMore, isLoading, loadMore, todos }: any) => {
  const { measureRef, isIntersecting, observer } = useOnscreen();

  useEffect(() => {
    if (isIntersecting && hasMore) {
      loadMore();
      observer.disconnect();
    }
  }, [isIntersecting, hasMore, loadMore]);

  return (
    <Fragment>
      {todos.map((todo: any, index: any) => {
        if (index === todos.length - 1) {
          return <ToDo mesureRef={measureRef} key={todo.id} todo={todo} />;
        }
        return <ToDo key={todo.id} todo={todo} />;
      })}
      {isLoading && (
        <div className="flex justify-center items-center h-[25vh] text-3xl">
          Loading...
        </div>
      )}
      {!hasMore && (
        <div className="flex justify-center items-center h-[25vh] text-3xl">
          Yay! you reached the end.
        </div>
      )}
    </Fragment>
  );
};

export default Content;

Below is the ToDo Component which renders the ui with dynamic ToDo data.

// src/components/ToDo.tsx
import React from "react";

const ToDo = ({ todo, mesureRef }: any) => {
  return (
    <article
      ref={mesureRef}
      className={`article bg-red-300 h-[20vh] flex items-center gap-5 flex-col justify-center m-10 rounded`}
    >
      <h3>
        Id: <strong>{todo.id}</strong>
      </h3>
      <h2 className="text-2xl font-semibold font-mono">{todo.title}</h2>
      <p>
        Status: <strong>{todo.completed ? "Completed" : "To Complete"}</strong>
      </p>
    </article>
  );
};

export default ToDo;

Below is useOnscreen hook which returns measure for targeting and observing the todo component element, an observer which can be disconnected or stopped as per requirement i.e. when we have already observed that element once we scrolled down to that element and isIntersecting which tells us whether that element is intersecting with root element which is our document's viewport and has appeared on our screen or whether we have reached that element.

//src/hooks/useOnscreen.ts
import { useCallback, useState } from "react";

const useOnscreen = ({
  root = null,
  rootMargin = "0px",
  threshold = 0,
} = {}) => {
  const [observer, setObserver]: any = useState();
  const [isIntersecting, setIntersecting] = useState(false);

  const measureRef = useCallback(
    (node: any) => {
      if (node) {
        const observer = new IntersectionObserver(
          ([entry]) => {
            setIntersecting(entry.isIntersecting);
          },
          { root, rootMargin, threshold }
        );

        observer.observe(node);
        setObserver(observer);
      }
    },
    [root, rootMargin, threshold]
  );

  return { measureRef, isIntersecting, observer };
};

export default useOnscreen;

You can replace basic text loading with better loading indicators. Incorporate error handlings in case of an unexpected response from API.

Remember that implementing infinite scrolling should be done thoughtfully, considering the nature of your content and your target audience's preferences. It's a powerful tool for enhancing user engagement, but it's not a one-size-fits-all solution. Depending on your specific use case, pagination or a "Load More" button may still be more appropriate.