๐Ÿš€ Implementing Infinite Scroll in React with IntersectionObserver to get UX smoother than 240Hz

July 10, 2025 (3w ago)

Implementing Infinite Scroll in React with IntersectionObserver

Infinite scroll is one of the most popular UX patterns for loading content on the web seen on social feeds like Instagram, Twitter, or YouTube. Instead of clicking "next page," new content loads automatically as the user scrolls.


โœ… Our Goal

We'll create two main pieces:

  1. A custom hook: useIntersectionObserver โ€” encapsulates the observer logic.
  2. A UI component: InfiniteScroll โ€” attaches to the hook, shows a loader or a "Load More" button.

1. useIntersectionObserver โ€“ The Hook

import { useEffect, useRef, useState } from "react";
 
export const useIntersectionObserver = (options?: IntersectionObserverInit) => {
  const [isIntersecting, setIsIntersecting] = useState(false);
  const targetRef = useRef<HTMLDivElement>(null);
 
  useEffect(() => {
    const observer = new IntersectionObserver(([entry]) => {
      setIsIntersecting(entry.isIntersecting);
    }, options);
 
    if (targetRef.current) {
      observer.observe(targetRef.current);
    }
 
    return () => observer.disconnect(); // Clean up on unmount
  }, [options?.root, options?.rootMargin, options?.threshold, options]);
 
  return { targetRef, isIntersecting };
};

What this does:

๐Ÿงช Example Usage:

const { targetRef, isIntersecting } = useIntersectionObserver({
  threshold: 0.5,
});

Here, the element is considered intersecting if 50% or more of it is visible.


2. InfiniteScroll โ€“ The UI Component

import { useIntersectionObserver } from "@/hooks/use-intersection-observer";
import { useEffect } from "react";
import { Button } from "./ui/button";
 
interface InfiniteScrollProps {
  isManual?: boolean;
  hasNextPage: boolean;
  isFetchingNextPage: boolean;
  fetchNextPage: () => void;
}
 
export const InfiniteScroll = ({
  isManual = false,
  hasNextPage,
  isFetchingNextPage,
  fetchNextPage,
}: InfiniteScrollProps) => {
  const { targetRef, isIntersecting } = useIntersectionObserver({
    threshold: 0.5,
    rootMargin: "100px", // start fetching slightly before the user reaches bottom
  });
 
  useEffect(() => {
    if (isIntersecting && hasNextPage && !isFetchingNextPage && !isManual) {
      fetchNextPage();
    }
  }, [
    isIntersecting,
    hasNextPage,
    isFetchingNextPage,
    isManual,
    fetchNextPage,
  ]);
 
  return (
    <div className="flex flex-col items-center gap-4 p-4">
      <div ref={targetRef} className="h-1" />
      {hasNextPage ? (
        <Button
          variant="secondary"
          disabled={isFetchingNextPage}
          onClick={fetchNextPage}
        >
          {isFetchingNextPage ? "Loading..." : "Load more"}
        </Button>
      ) : (
        <p className="text-xs text-muted-foreground">
          You have reached the end of the list.
        </p>
      )}
    </div>
  );
};

โš™๏ธ Key Props

๐Ÿงฉ Explanation:


๐Ÿงช Example Use Case in App

const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
  trpc.somePaginatedQuery.useInfiniteQuery({ limit: 10 });
 
<InfiniteScroll
  fetchNextPage={fetchNextPage}
  hasNextPage={hasNextPage}
  isFetchingNextPage={isFetchingNextPage}
/>;

You can also pass isManual={true} to disable auto-fetching and only rely on the button.