React|Custom Hooks

useIntersectionObserver: The Core of Infinite Scroll and Lazy Loading

2
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.

typescript
// โŒ 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.

typescript
// 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.

typescript
// 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.

typescript
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

  • Problem: Judging visibility using scroll events degrades performance due to frequent event firing and layout recalculation (Reflow).
  • Solution: The IntersectionObserver API asynchronously monitors element intersection using an optimized method at the browser level.
  • useIntersectionObserver: Encapsulates complex observer setup and cleanup logic into a hook, making visibility detection easy just by connecting a ref.
  • Usage: Enables high-performance implementation of modern web essentials like Infinite Scroll, Lazy Image Loading, and Scroll-triggered animations.

  • 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."

  • Why: You understand and can control why rendering occurs.
  • How: You can elegantly handle complex async states and forms.
  • Structure: You can design maintainable folder structures and components.
  • 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.

    ๐Ÿ”— References

  • MDN - Intersection Observer API
  • usehooks-ts: useIntersectionObserver
  • Comments (0)

    0/1000 characters
    Loading comments...
    useIntersectionObserver: The Core of Infinite Scroll and Lazy Loading | VXD Blog