useIntersectionObserver: The Core of Infinite Scroll and Lazy Loading

Chris is building a product list page for a shopping mall.
Since there are over 1,000 products, he can't show them all at once. He needs to implement Infinite Scroll, where new products load as the scroll hits the bottom, just like on Facebook.
Chris used the method he was most familiar with: the scroll event.
// โ Chris's Performance Killer
useEffect(() => {
const handleScroll = () => {
// Mathematically calculating scroll position every time
const { scrollTop, clientHeight, scrollHeight } = document.documentElement;
// Check pixel by pixel if we reached the bottom
if (scrollTop + clientHeight >= scrollHeight - 10) {
loadMoreItems();
}
};
// Runs hundreds of times per scroll (Hogs the Main Thread)
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);The feature works, but the laptop fan starts screaming. This is because the scroll event fires dozens of times per second, and each time, the browser screams while recalculating layout metrics like scrollTop (Reflow).
Even using Throttle doesn't reduce the cost of the calculation itself.
To solve this problem, let's wrap the browser's elegant solution, the IntersectionObserver API, into a hook.
1. IntersectionObserver: "The Observer Pattern"
The concept of this API is to plant an "Observer."
We place a transparent element called "Bottom" at the very end of the list. Then, we command the observer:
"Radio me when about 10% of that 'Bottom' element becomes visible in the Viewport."
There is no need to constantly monitor scroll events. Through logic optimized internally by the browser, it executes a callback exactly once when the element intersects with the screen.
2. Implementing useIntersectionObserver
This API's usage is slightly complex (new, observe, disconnect, etc.). Let's wrap it in a "React-ful" way.
// useIntersectionObserver.ts
import { useEffect, useState, RefObject } from 'react';
// Option Type (Sensitivity control, etc.)
interface Args extends IntersectionObserverInit {
freezeOnceVisible?: boolean; // Whether to stop observing once visible
}
export function useIntersectionObserver(
elementRef: RefObject<Element>,
{
threshold = 0.1, // Detect when 10% visible
root = null, // null means Browser Viewport
rootMargin = '0%', // Margin (Use to detect beforehand)
freezeOnceVisible = false,
}: Args = {}
): IntersectionObserverEntry | undefined {
const [entry, setEntry] = useState<IntersectionObserverEntry>();
const [frozen, setFrozen] = useState(false);
useEffect(() => {
const node = elementRef?.current; // DOM element to observe
const hasIOSupport = !!window.IntersectionObserver;
// Pass if not supported or already frozen
if (!hasIOSupport || frozen || !node) return;
const observerParams = { threshold, root, rootMargin };
const observer = new IntersectionObserver(([entry]) => {
// Update intersection state
setEntry(entry);
// Stop observing if we only need to detect once (e.g., Lazy Loading)
if (entry.isIntersecting && freezeOnceVisible) {
setFrozen(true);
}
}, observerParams);
observer.observe(node); // Start observing
return () => observer.disconnect(); // Cleanup: Stop observing
}, [elementRef, JSON.stringify(threshold), root, rootMargin, frozen]);
return entry;
}3. Real-World Application 1: Infinite Scroll
Now Chris just needs to place a single invisible div at the bottom.
// ProductList.tsx
function ProductList() {
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery(...);
// 1. Create Ref for bottom detection
const bottomRef = useRef<HTMLDivElement>(null);
// 2. Connect Hook
const entry = useIntersectionObserver(bottomRef, {});
const isIntersecting = !!entry?.isIntersecting;
// 3. Fetch next page when bottom is visible
useEffect(() => {
if (isIntersecting && hasNextPage) {
fetchNextPage();
}
}, [isIntersecting]);
return (
<div>
{data.map(item => <ProductItem key={item.id} {...item} />)}
{/* Load more when this transparent box enters the screen */}
<div ref={bottomRef} style={{ height: 20 }} />
</div>
);
}Without complex math, intuitive code stating "Load when the bottom is seen" is complete.
4. Real-World Application 2: Lazy Image Loading
Beyond infinite scroll, it can be used for image optimization. Do not load the image until it enters the screen, then load it the moment it does.
function LazyImage({ src, alt }: { src: string; alt: string }) {
const ref = useRef<HTMLImageElement>(null);
// Stop observing once seen (freezeOnceVisible: true)
const entry = useIntersectionObserver(ref, { freezeOnceVisible: true });
const isVisible = !!entry?.isIntersecting;
return (
<div ref={ref} className="image-placeholder">
{/* Assign src only when it enters the screen */}
{isVisible && <img src={src} alt={alt} />}
</div>
);
}By doing this, images in the lower area where the user hasn't scrolled yet do not send network requests, dramatically speeding up the initial load time.
Key Takeaways
We have climbed all the way from the basics of useState to the latest features of React 19, architecture, performance optimization, and creating our own Custom Hooks.
Now, you are no longer just a "coder who draws screens."
Your project is just beginning. I hope you take the "Principle-Centered Mindset" learned here as your weapon and challenge yourself with more complex and interesting problems.