React|Server State and Async Data

useInfiniteQuery and Intersection Observer: The Standard for Infinite Scroll

0
useInfiniteQuery and Intersection Observer: The Standard for Infinite Scroll

Chris is building a product list for an e-commerce site. Initially, he placed traditional pagination buttons like [1] [2] [3] at the bottom.

However, testing it on mobile, he found the buttons were too small to press comfortably, and the browsing flow felt disjointed and interrupted.

"Modern apps just keep loading more content as you scroll down. Can't we change ours to work like that?"

He's referring to Infinite Scroll, which provides a seamless user experience like Instagram or Twitter. To implement this, we need a technology to manage "accumulating" data and a way to detect if the user has "hit the bottom."

Today, we will explore how to implement perfect infinite scroll by combining TanStack Query's useInfiniteQuery and the browser's Intersection Observer API.

1. Data Management: useInfiniteQuery

Standard useQuery overwrites existing data when the page number changes. However, infinite scroll needs to "append" new data after the existing data. This is where useInfiniteQuery comes in.

Core Concepts: pageParam and getNextPageParam

  • pageParam: A variable that determines which page to fetch currenty. (Initial value is usually 0 or 1).
  • getNextPageParam: A function that looks at the server response and calculates "what the next page number is." If there is no more data, it returns undefined to stop fetching.
  • // useProducts.ts
    import { useInfiniteQuery } from '@tanstack/react-query';
    
    export function useProducts() {
      return useInfiniteQuery({
        queryKey: ['products'],
        queryFn: async ({ pageParam = 1 }) => {
          // API request using pageParam
          const res = await fetch(`/api/products?page=${pageParam}`);
          return res.json();
        },
        initialPageParam: 1,
        // Logic to calculate the next page
        getNextPageParam: (lastPage, allPages) => {
          // If the last page has fewer than 10 items, consider it finished
          const nextPage = allPages.length + 1;
          return lastPage.items.length < 10 ? undefined : nextPage;
        },
      });
    }

    By doing this, data stacks up neatly inside an array called data.pages (e.g., [[Page 1 Data], [Page 2 Data], ...]).

    2. Bottom Detection: Intersection Observer

    Now we need to determine if the user has scrolled to the bottom.

    In the past, we calculated this by attaching a scroll event listener, but this negatively impacts performance (triggers Reflow).

    Nowadays, we use the Intersection Observer API. We plant a transparent bar (Sentinel) at the bottom of the screen and watch to see if this bar appears (Intersect) on the screen.

    Creating a Custom Hook

    Setting up the Observer every time is tedious, so let's create a reusable hook.

    // useIntersectionObserver.ts
    import { useEffect, useRef } from 'react';
    
    export function useIntersectionObserver(
      onIntersect: () => void, // Function to execute when detected
      options?: IntersectionObserverInit
    ) {
      const targetRef = useRef<HTMLDivElement>(null);
    
      useEffect(() => {
        if (!targetRef.current) return;
    
        const observer = new IntersectionObserver((entries) => {
          // If target enters the screen (isIntersecting), run callback
          if (entries[0].isIntersecting) {
            onIntersect();
          }
        }, options);
    
        observer.observe(targetRef.current);
    
        return () => observer.disconnect();
      }, [onIntersect, options]);
    
      return targetRef;
    }

    3. Integration: The Infinite Scroll Component

    Now, let's combine the two technologies. We place a div at the very end of the list, and when that div becomes visible, we execute fetchNextPage().

    // ProductList.tsx
    import { useProducts } from './useProducts';
    import { useIntersectionObserver } from './useIntersectionObserver';
    
    function ProductList() {
      const { 
        data, 
        fetchNextPage, 
        hasNextPage, 
        isFetchingNextPage 
      } = useProducts();
    
      // Execute fetchNextPage when hitting the bottom
      const loadMoreRef = useIntersectionObserver(() => {
        if (hasNextPage) fetchNextPage();
      });
    
      return (
        <div>
          {/* 1. Flatten and render accumulated data */}
          {data?.pages.map((page) =>
            page.items.map((product) => (
              <ProductCard key={product.id} data={product} />
            ))
          )}
    
          {/* 2. Loading indicator and Sentinel for detection */}
          <div ref={loadMoreRef} style={{ height: '20px' }}>
            {isFetchingNextPage && <Spinner />}
          </div>
        </div>
      );
    }

    4. UX Details: Bidirectional Scroll and Virtualization

    What happens if the data grows to 100 or 1000 items? The browser will lag because there are too many DOM nodes.

    The technology needed here is Virtualization. It draws only the parts visible on the screen and removes the parts that have passed. We will cover this in detail in the next part (Optimization).

    Also, for cases like chat rooms where you need to load past data when scrolling up, you might need bidirectional infinite scroll. useInfiniteQuery also supports getPreviousPageParam, so you can implement it using the same principle.

    Key Takeaways

  • useInfiniteQuery: A React Query hook that manages accumulated pagination data. Configuring getNextPageParam is key.
  • data.pages: Fetched data is stored as a 2D array ([[page1], [page2]...]), so you need to use map twice or use flatMap.
  • Intersection Observer: Detects if the bottom is reached by watching an element instead of using scroll events, avoiding performance degradation.
  • Sentinel Element: An invisible element (div) placed at the end of the list to trigger the loading of the next page.

  • This concludes Part 3: Server State and Async Data.

    Now that we've learned how to fetch data effectively, it's time to learn techniques to make the app faster and lighter. If you call an API every time a user types in a search bar, the server won't survive.

    Part 4: Optimization & Security begins with the first topic: “useDebounce vs useThrottle: Optimizing Search Autocomplete and Scroll Events”

    🔗 References

  • TanStack Query - Infinite Queries
  • MDN - Intersection Observer API