React | Infinite Scroll 구현하기 (react-query, react-intersection-observer)

무한 스크롤

무한 스크롤이란 사용자가 페이지 하단에 도달했을 때, 컨텐츠가 계속 로드되는 사용자 경험(UX) 입니다. 무한 스크롤을 구현하기 위해 필요한 기술은 다음과 같습니다.

  1. 컨텐츠 fetch API
  2. 페이지 하단 요소 관찰
  3. 새로운 컨텐츠 추가

컨텐츠를 가져오는 data fetch 는 캐싱과 최적화가 가능한 react-query 를, 페이지 하단 요소 관찰을 위해서 react-intersection-observer 를 활용하여 구현해보도록 하겠습니다.

[useInfiniteQuery]

리액트에서 데이터를 fetch, cache, update 하는 라이브러리인 React Query에서 useInfiniteQuery Hook을 제공합니다.

React Query 공식 문서에 따르면 다음과 같은 매개변수와 반환 값을 가집니다.

const {
  fetchNextPage,
  fetchPreviousPage,
  hasNextPage,
  hasPreviousPage,
  isFetchingNextPage,
  isFetchingPreviousPage,
  ...result
} = useInfiniteQuery(
    queryKey, 
    ({ pageParam = 1 }) => fetchPage(pageParam), 
    {
      ...options,
      getNextPageParam: (lastPage, allPages) => lastPage.nextCursor,
      getPreviousPageParam: (firstPage, allPages) => firstPage.prevCursor,
    }
);

매개변수

1. 첫 번째 인수 - queryKey

react-query가 데이터를 캐싱할 때 사용하는 key값 입니다.
개발자가 임의로 지정할 수 있습니다.
불러오는 데이터 마다 다른 key 값을 사용해야합니다.

2. 두 번째 인수 - fetch 함수

({ pageParam = 1 }) => fetchPage(pageParam)

두 번째 인수는 데이터를 요청하는 API 함수가 포함된 함수입니다.
fetchPage 는 응답으로 받은 값을 반환해야합니다.
무한스크롤을 구현해야 하는 상황에서는 다음 요청을 위한 데이터도 포함해서 반환할 수 있습니다.

예를들어 현재는 1 페이지 데이터를 요청했다면, 다음에는 2 페이지 데이터를 요청하도록 반환값에 결과값과 함께 담아서 내보낼 수 있습니다.

pageParam 는 이때 사용하는 값으로, 초기값으로 처음에 시작할 페이지를 설정할 수 있습니다.

정리하자면 다음과 같이 nextPageisLast 정보를 결과 값과 함께 내보내면, 다음 요청시 활용할 수 있습니다.

const fetchPage = ({ pageParam = 0 }) => {
  // API
  const { data } = await getData({ startIndex: pageParam });
  
  // 다음 요청시 사용할 nextPage와 isLast
  return {
    result: data,
    nextPage: pageParam + 1,
    isLast: data.isLast,
  }
}

3. 세 번째 인수 - options

{
  ...options,
    getNextPageParam: (lastPage, allPages) => lastPage.nextCursor,
}

마지막 인수는 options로 추가적인 기능을 제공합니다.

getNextPAgeParam 는 추가적으로 데이터를 fetch 하는 경우에, 두 번째 인수였던 콜백 함수가 반환한 값을 가져와서 사용할 수 있습니다. 따라서 위의 예제에서는 nextPage 를 객체에 담아서 반환했으므로, lastPage.nextPage 로 사용할 수 있습니다.

getNextPageParam 는 반드시 하나의 값만 반환해야하며, 그 값은 두 번째 인수인 콜백 함수를 호출할 때 인수로 전달됩니다.

만약 undefined 를 반환하는 경우엔 fetch 콜백을 호출하지 않기 때문에, 마지막 페이진 경우엔 이를 활용하면 되겠습니다.

반환값

1. fetchNextPage 콜백

해당 함수를 호출하면 다음 페이지의 데이터를 요청할 수 있습니다. 이 경우 두 번째 인수로 넘겨졌던 콜백이 다음 페이지 정보를 매개변수로 받은 다음 호출됩니다.

2. isFetching

useQueryisLoading 과는 다르게 isFetching 을 통해서 로딩중임을 확인할 수 있습니다.

3. data

data는 각각의 페이지별 데이터가 리스트 형태입니다. 그리고 페이지별 데이터는 두 번째 인수였던 콜백의 반환값이 들어가게 됩니다.

위의 콜백을 사용하였다면 data 는 다음과 같습니다.

const data = {
  pages: [
    {
      result,
      nextPage,
      isLast,
    },
    {
      result,
      nextPage,
      isLast,
    },
    // ...
    ]
}

[useInView]

Intersection Observer API 는 기본 JS 스펙에 포함되어있는 API입니다.
리엑트에서 Intersection Observer API를 사용하기 위해서는 React Intersection Observer 라이브러리를 활용할 수 있습니다.

라이브러리 문서에 따르면 다음과 같이 useInView Hook을 사용할 수 있습니다.

import React from 'react';
import { useInView } from 'react-intersection-observer';

const Component = () => {
  const [ref, inView] = useInView();

  return (
    <div ref={ref}>
      <h2>{`Header inside viewport ${inView}.`}</h2>
    </div>
  );
};

반환값

1. ref

관찰할 요소에게 ref 를 전달하면 됩니다.

2. inView

boolean 값으로, 관찰을 진행하는 요소가 viewport 상에서 나타나면 true 가 반환되며 그 외에는 false 가 반환됩니다.

[무한 스크롤 구현하기]

위의 두 개의 기술을 사용하여 무한 스크롤을 구현하였습니다.

반환값중 관찰할 컴포넌트(ObservationComponent)를 컨텐츠의 가장 하단에 추가하면 정상적으로 동작합니다.

ObservationComponent 가 관찰되면, inView 값이 변경될 것이므로 useEffect 를 사용하여 fetch 함수를 호출하였습니다.

import { ReactElement, useEffect } from 'react';
import { useInView } from 'react-intersection-observer';
import { useInfiniteQuery } from 'react-query';

import { AnyObject } from 'immer/dist/internal';

import { DEFAULT_SEARCH_QUANTITY, SearchType } from '@type/web/event';

import { getEventList } from '@api/event/event';

interface UseInfiniteQueryWithScrollParamsTypes {
  currentSearchType: SearchType;
  queryString: AnyObject;
}

interface UseInfiniteQueryWithScrollReturnTypes {
  data: any[] | undefined;
  error: string | undefined | unknown;
  isFetching: boolean;
  ObservationComponent: () => ReactElement;
}

/**
 * 사용 기술
 * Recat query: useInfiniteQuery (https://react-query.tanstack.com/guides/infinite-queries)
 * react-intersection-observer: useInView
 */
export default function useInfiniteQueryWithScroll({
  currentSearchType,
  queryString,
}: UseInfiniteQueryWithScrollParamsTypes): UseInfiniteQueryWithScrollReturnTypes {
  const getEventListWithPageInfo = async ({ pageParam = 0 }) => {
    const { data } = await getEventList({
      ...queryString,
      eventType: currentSearchType,
      searchType: currentSearchType,
      startIndex: pageParam,
    });

    const nextPage =
      data.length >= DEFAULT_SEARCH_QUANTITY ? pageParam + 1 : undefined;

    return {
      result: data,
      nextPage,
      isLast: !nextPage,
    };
  };

  const { data, error, isFetching, fetchNextPage } = useInfiniteQuery(
    [`eventListData-${currentSearchType}`],
    getEventListWithPageInfo,
    {
      getNextPageParam: (lastPage) => lastPage.nextPage,
    },
  );

  const ObservationComponent = (): ReactElement => {
    const [ref, inView] = useInView();

    useEffect(() => {
      if (!data) return;

      const pageLastIdx = data.pages.length - 1;
      const isLast = data?.pages[pageLastIdx].isLast;

      if (!isLast && inView) fetchNextPage();
    }, [inView]);

    return <div ref={ref} />;
  };

  return {
    data: data?.pages,
    error,
    isFetching,
    ObservationComponent,
  };
}

좋은 웹페이지 즐겨찾기