React-query 환경에서 효율적으로 무한스크롤 기능을 구현하는 방법

React-query 환경에서 효율적으로 무한스크롤 기능을 구현하는 방법

Tags
설계
React-query
Published
March 6, 2024
Author
Seongbin Kim
 

1. 요구 사항

다음 기능을 구현하는 방법을 설명해요
  • useInfiniteQuery를 사용해서 page 별로 조회하는 방법
  • 스크롤 시 자동 로딩 기능을 공통 컴포넌트로 구현하는 방법
    • 스크롤 컨테이너의 일정 높이에 도달했을 때 다음 페이지를 조회하는 기능
    • 로딩 중에는 하단에 로딩 스피너를 보여주는 기능
⚠️
(1) 아래 방향 무한 스크롤 구현에 필요한 내용만 설명해요! (2) Suspense 사용을 기준으로 해요!
notion image
notion image
 

2. 구현

 

2-1. useInfiniteQuery 사용 방법 이해하기

 
useQuery를 쓸 때보다 page 관련 정보를 추가로 제공해야 돼요.
 

2-1-1. useQuery와의 차이점과 기본 개념

 
data 필드의 응답 객체 형식이 바뀌어요.
  • pages[], pageParams[] 필드로 구성돼요.
    • data: { pageParams: [ 1, ], pages: [ { /* useQuery로 받던 원래 데이터 객체 */ }, // 이후 페이지의 조회 결과가 개별 객체 단위로 담겨요. ], }
    • 첫 페이지의 객체는 data.pages[0]로 접근할 수 있어요.
      • 아래 예시는 data 객체의 필드가 data, page인 경우의 예시에요.
        • (즉 data, page 객체의 내용은 response json body에 따라 달라요)
        • 1페이지만 조회했을 때에요:
          • notion image
        • 3페이지를 조회한 상태에요:
          • notion image
          • data 객체가 페이지 별로 분리되어 있기 때문에, 전체 페이지를 조회하려면 다 합쳐줘야 돼요.
            • // 예시의 API는 data 객체의 data 필드가 배열이라서 flatMap을 해줘요. const allPagesMerged = pages.flatMap(page => page.data);
 
 

2-1-2. useQuery와의 차이점: useInfiniteQuery 옵션

useInfiniteQuery({ queryKey, queryFn, initialPageParam: 1, getNextPageParam: () => {}, })
옵션 프로퍼티가 2개 있어요.
  • initialPageParam (숫자) → 첫 페이지의 숫자 반환 (보통 1)
  • getNextPageParam (함수) → 다음 페이지의 숫자 반환 (끝이라면 undefined)
    • page, pageParam을 제공해주기 때문에 해당 객체의 값을 바탕으로 다음 페이지를 반환할 수 있어요.
    • 보통 lastPage만으로 충분해요.
    • // getNextPageParam 콜백의 타입 (lastPage, allPages, lastPageParam, allPageParams) => nextPage, // 실제 사용 예시 // lastPage(이미 가져온 마지막 페이지의 data 객체)만 사용하는 경우에요. getNextPageParam: ({ page: { current, last } }) => current < last ? current + 1 : undefined,
 

2-1-3. queryFn 차이점: 신규 파라미터

파라미터 객체에 pageParam이라는 필드가 들어와요. 그대로 조회 API에 전달하기만 하면 돼요.
notion image
 
예시
queryFn: ({ pageParam }) => kakaoMapSearch(searchTerm, pageParam),
 

2-1-4. 입력 옵션, pageParam 정리

정리하자면 아래와 같이 InfiniteQuery를 사용할 수 있어요.
// useGetInfiniteKakaoMapApi const { data: { pages } } = useSuspenseInfiniteQuery({ queryKey: ['locations', 'infinite', searchTerm], queryFn: ({ pageParam }) => kakaoMapSearch(searchTerm, pageParam), initialPageParam: 1, getNextPageParam: ({ page: { current, last } }) => current < last ? current + 1 : undefined, }); const locations = pages.flatMap(page => page.data); return { locations };
 

2-1-5. 이후 페이지를 fetch할때 필요한 값과 함수

useInfiniteQuery는 추가로 다음의 필드들을 제공해요.
  • hasNextPage: boolean
  • fetchNextPage: () => void
  • fetchStatus: 'idle' | 'fetching' | 'paused'
const { data: { pages }, hasNextPage, fetchStatus, fetchNextPage, } = useSuspenseInfiniteQuery({
이 세 개의 값은 화면에서 사용되기 때문에 함께 노출해주는 게 좋아요.
 
정리하자면 아래와 같이 작성할 수 있어요.
export const useGetInfiniteKakaoMapSearchApi = (searchTerm: string) => { const { data: { pages }, hasNextPage, fetchStatus, fetchNextPage, } = useSuspenseInfiniteQuery({ queryKey: ['kakao-map-search', 'infinite', searchTerm], queryFn: ({ pageParam }) => kakaoMapSearch(searchTerm, pageParam), initialPageParam: 1, getNextPageParam: ({ page: { current, last } }) => current < last ? current + 1 : undefined, }); const allPagesMerged = pages.flatMap(page => page.data); return { locations: allPagesMerged, hasNextPage, fetchStatus, fetchNextPage, }; };

2-2. 스크롤 컨테이너의 일정 높이 도달 시 호출하는 로직 구현

 
요구 사항
상황: 사용자가 스크롤을 통해 스크롤 컨테이너의 높이의 일정 위치에 도달하면
행동: fetchNextPage를 호출해요.
 
구현 방법
  • IntersectionObserver를 사용해요.
    • 특정 요소가 화면에 보이면 fetch를 호출해요.
  • 특정 요소를 로딩을 시작해야 될 스크롤 지점에 배치해요.
    • 부모를 relative로 만들고, 임의의 divabsolute로 만들어요.
    • 부모의 boundingRect를 사용해 height를 얻어요.
    • 임의의 divbottom을 할당해요.
  • 로딩이 끝나면 새로 y값을 계산해 임의의 div의 위치를 새로 지정해요.
 

2-2-1. 간단한 구현

 
좌표는 boundingRect로 쉽게 얻어와요.
// y 좌표 계산 const caculateBottomY = (element: HTMLElement, ratio: number) => { const { height } = element.getBoundingClientRect(); return Math.floor(height * ratio); };
 
container, marker 요소의 DOM은 ref로 가져와요.
// container, marker 요소의 DOM을 보관 const containerRef = useRef<HTMLDivElement>(null); const markerRef = useRef<HTMLDivElement>(null); // y 좌표를 보관 const [markerY, setMarkerY] = useState(0); // 화면에 요소를 렌더링 <div ref={containerRef} className={cn(className, 'relative')} {...props}> {children} <FetchMarker ref={markerRef} y={markerY} /> </div> // FetchMarker 요소 const FetchMarker = forwardRef<HTMLDivElement, FetchMarkerProps>( ({ y }, ref) => ( <div ref={ref} className='absolute' style={{ bottom: y, left: 0 }} /> ), );
 
IntersectionObserver를 사용해 marker 요소가 보이면 fetch를 호출해요.
// 인터섹션 옵저버를 생성해 화면에 보이면 fetch를 호출 useEffect(() => { const markerElement = markerRef.current; const markerObserver = new IntersectionObserver(entries => { if (entries[0]?.isIntersecting && hasNextPage) { fetchNextPage(); } }); markerObserver.observe(markerElement); }, []);
 
로딩 상태가 바뀌면 (fetching → idle) 새로운 높이에 맞춰 marker 요소의 높이를 갱신해요.
// fetchStatus가 바뀌면 y값을 새로 지정 useEffect(() => { const containerElement = containerRef.current; // fetching->idle 로 바뀔 때만 Y를 갱신해야 돼요. if (fetchStatus === 'fetching') { return; } const bottomY = caculateBottomY(containerElement, bottomYRatio); setMarkerY(bottomY); }, [fetchStatus]);
 

2-2-2. 화면 하단에 스피너 표시하기

 
스피너 표시는 쉽게 할 수 있어요.
// 화면에 요소를 렌더링 <div ref={containerRef} className={cn(className, 'relative')} {...props}> {children} <FetchMarker ref={markerRef} y={markerY} /> {/* fetching 상태일 때 loaderElement를 렌더링하기 */} {fetchStatus === 'fetching' && loaderElement} </div>
 

2-2-3. 공통 컴포넌트로 구현하기

 
다음과 같이 사용할 수 있도록 공통 컴포넌트로 만들 수 있어요.
const { locations, hasNextPage, fetchStatus, fetchNextPage } = useGetInfiniteKakaoMapSearchApi(searchWord); return ( <InfiniteScrollAutoFetcher hasNextPage={hasNextPage} fetchNextPage={fetchNextPage} fetchStatus={fetchStatus} loaderElement={<SpinnerListBottom />} > {locations.map(location => ( <LocationListItem key={location.id} location={location} onClick={onSelect} /> ))} </InfiniteScrollAutoFetcher> );
 
구현 코드
/* 기타 import 생략 */ import Spinner from '../Spinner'; import FetchMarker from './FetchMarker'; const caculateBottomY = (element: HTMLElement, ratio: number) => { const { height } = element.getBoundingClientRect(); return Math.floor(height * ratio); }; interface InfiniteScrollAutoFetcherProps extends ComponentPropsWithoutRef<'div'> { fetchStatus: 'fetching' | 'paused' | 'idle'; hasNextPage: boolean; fetchNextPage: () => void; bottomYRatio?: number; loaderElement?: ReactNode; } /** * 스크롤 시 해당 요소의 일정 비율의 높이에 도달하면 fetchNextPage를 호출해요. * * `bottomYRatio`로 높이를 지정할 수 있어요. (e.g. `0.3 = 아래에서 30% 높이일 때`) * * React-query의 useInfiniteQuery와 함께 쓰면 가장 편해요. */ const InfiniteScrollAutoFetcher = ({ fetchStatus, hasNextPage, fetchNextPage, children, className, bottomYRatio = 0.3, loaderElement = <Spinner />, ...props }: InfiniteScrollAutoFetcherProps) => { const containerRef = useRef<HTMLDivElement>(null); const markerRef = useRef<HTMLDivElement>(null); const [markerY, setMarkerY] = useState(0); useEffect( function createObserverOnMount() { const markerElement = markerRef.current; if (!markerElement) { return; } const markerObserver = new IntersectionObserver(entries => { if (entries[0]?.isIntersecting && hasNextPage) { fetchNextPage(); } }); markerObserver.observe(markerElement); }, [hasNextPage, fetchNextPage], ); useEffect( function moveMarkerToBottomOnFetch() { const containerElement = containerRef.current; // fetching->idle 로 바뀔 때만 Y를 갱신해야 돼요. if (!containerElement || fetchStatus === 'fetching') { return; } const bottomY = caculateBottomY(containerElement, bottomYRatio); setMarkerY(bottomY); }, [fetchStatus, bottomYRatio], ); return ( <div ref={containerRef} className={cn(className, 'relative')} {...props}> {children} <FetchMarker ref={markerRef} y={markerY} /> {fetchStatus === 'fetching' && loaderElement} </div> ); }; export default InfiniteScrollAutoFetcher;