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:
- A custom hook:
useIntersectionObserver
โ encapsulates the observer logic. - 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:
targetRef
: Thisref
is attached to the DOM node we want to observe.IntersectionObserver
: It checks if the target is visible in the viewport.setIsIntersecting
: Updates state when the node enters/exits the viewport.- Cleanup: On component unmount or re-render, the observer is disconnected to avoid memory leaks.
๐งช 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
hasNextPage
: Controls whether there's more data to fetch.isFetchingNextPage
: Prevents duplicate fetches.fetchNextPage()
: Function to load the next page.isManual
: Toggle between auto-triggered and user-triggered loading.
๐งฉ Explanation:
- The
targetRef
is attached to an invisible<div>
(h-1
). - When the user scrolls close enough (within
rootMargin: "100px"
), and the threshold is met (0.5
), the intersection triggers. - If
isManual
is false, data is fetched automatically. - If
isManual
is true, the button must be clicked to load more.
๐งช 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.