[React Query] infinite scroll

먼지·2022년 10월 5일
12

React Query

목록 보기
2/4
post-thumbnail

Infinite Queries

https://react-query-v3.tanstack.com/guides/infinite-queries#_top

정리하기


React 쿼리를 사용한 무한 스크롤

https://blog.openreplay.com/infinite-scrolling-with-react-query/
이 블로그에 리액트 쿼리 useInfiniteQuery랑 무한 스크롤 구현하는 법 다 나와있음...!! 따라 쓰면서 무작정 적용은 했는데 다 영어라 모르겠어서 대충 번역해서 이해해야겠다

React에서 무한 스크롤 구현하기

React Query는 무한 스크롤을 위한 useInfiniteQuery 훅을 제공함. useQuery와 비슷하며 몇 가지 차이점은:

  • 반환된 데이터는 이제 두 개의 배열 속성을 포함하는 객체입니다. 하나는 페이지를 가져오는 데 사용되는 페이지 매개변수를 포함하는 배열인 data.pageParams를 사용한 pageParams 액세스. 다른 하나는 가져온 페이지를 포함하는 배열인 data.pages를 사용한 pages access
  • 세 번째 매개변수로 전달되는 옵션에는 로드할 데이터가 더 있는지 판단하기 위한 getNextPageParamgetPreviousPageParam이 있습니다.
  • fetchNextPagefetchPreviousPage 함수는 각각 다음 페이지와 이전 페이지를 가져오기 위한 반환 속성으로 포함되어 있습니다.
  • hasNextPagehasPreviousPage 부울 속성이 반환되어 다음 또는 이전 페이지가 있는지 확인합니다.
  • isFetchingNextPageisFetchingPreviousPage boolean 속성이 반환되어 다음 페이지 또는 이전 페이지를 가져올 때 확인합니다.

무한 스크롤을 구현하려면 위의 몇 가지 options/properties만 사용하면 됩니다. 먼저 useQuery 후크를 useInfiniteQuery로 교체하고 starter app에서 데이터를 가져오는 데 사용되는 함수도 업데이트합니다. 이렇게 하려면 App.js 파일로 이동하여 다음 import를 추가하고 App 컴포넌트에서 fetchRepositoriesuseQuery를 다음 코드로 바꿉니다.

// App.js 
import { useInfiniteQuery } from "react-query"

function App() {
  const LIMIT = 10
  
  const fetchRepositories = async (page) => {
    const response = await fetch(`https://api.github.com/search/repositories?q=topic:react&per_page=${LIMIT}&page=${page}`)
    return response.json()
  }
  
  const {data, isSuccess, hasNextPage, fetchNextPage, isFetchingNextPage} = useInfiniteQuery(
    'repos', 
    ({pageParam = 1}) => fetchRepositories(pageParam),
    {
      getNextPageParam: (lastPage, allPages) => {
        const nextPage = allPages.length + 1
        return nextPage 
      }
    }
  )
  
  return (  
    <div className="app">
      {isSuccess && data.pages.map(page => 
        page.items.map((comment) => (
          <div className='result' key={comment.id}>
            <span>{comment.name}</span>
            <p>{comment.description}</p>
          </div>
        ))
      )}
    </div>
  );
}

이제 가져온 결과가 표시되어야 합니다. 무한 스크롤이 작동하려면 페이지 맨 아래로 스크롤할 때마다 fetchNextPage 함수를 호출해야 합니다. 페이지 맨 아래에 도달하는 시점을 확인하기 위해 브라우저 Scroll 이벤트 또는 Intersection Observer API를 사용하여 이를 수행할 수 있습니다. 다음 섹션에서 이 두 가지를 모두 다룰 것입니다.

스크롤 이벤트를 사용하여 새 데이터 가져오기

// App.js
import { useEffect } from 'react';

useEffect(() => {
  let fetching = false;
  const handleScroll = async (e) => {
    const {scrollHeight, scrollTop, clientHeight} = e.target.scrollingElement;
    if(!fetching && scrollHeight - scrollTop <= clientHeight * 1.2) {
      fetching = true
      if(hasNextPage) await fetchNextPage()
      fetching = false
    }
  }
  document.addEventListener('scroll', handleScroll)
  return () => {
    document.removeEventListener('scroll', handleScroll)
  }
}, [fetchNextPage, hasNextPage])

위의 코드에서, document에 스크롤 이벤트 리스너를 추가했습니다. 이 리스너는 실행될 때 handleScroll 함수를 호출합니다. 여기에서 scrollingEvent 속성을 사용하여 페이지 맨 아래에 도달했을 때를 감지 한 다음 hasNextPagetruefetchNextPage를 호출하여 다음 페이지를 가져옵니다.

지금은 getNextPageParam 옵션에서 다음 페이지를 가져오기 위한 값인 매개변수를 반환하기 때문에 hasNextPage는 항상 true입니다. hasNextPagefalse가 되려면 getNextPageParam에서 undefined 또는 다른 거짓 값을 반환해야 합니다. 나중에 이 작업을 수행하여 맨 아래로 스크롤한 후 fetching data events를 중지하는 기능을 만들 것입니다.

이를 통해 앱을 시작하고 페이지 하단으로 스크롤할 때 새 데이터를 가져옵니다.

Intersection Observer를 사용하여 새 데이터 가져오기

Intersection Observer API는 포함하는 root element 또는 viewport를 기준으로 DOM 요소의 가시성과 위치를 관찰하는 방법을 제공합니다. 간단히 말해서 관찰된 요소가 표시되거나 미리 정의된 위치에 도달할 때 모니터링하고 제공된 콜백 함수를 실행합니다. 이 API를 사용하여 무한 스크롤 기능을 구현하려면 먼저 가져온 데이터의 맨 아래에 관찰된 요소가 될 요소를 만듭니다. 그런 다음 이 요소가 표시되면 fetchNextPage 함수를 호출합니다.

// App.js
import {useRef, useCallback} from 'react'

<div className="app">
  ...
  <div className='loader' ref={observerElem}>
    {isFetchingNextPage && hasNextPage ? 'Loading...' : 'No search left'}
  </div>
</div>

위에서 Intersection Observers를 사용하여 관찰하려는 div 요소를 만들었습니다. 직접 액세스할 수 있도록 ref 속성을 추가했습니다. 위의 divisFetchingNextPagehasNextPage boolean 값에 따라 Loading… 또는 No search left를 표시합니다.

다음으로 App 컴포넌트 상단에 다음 코드 라인을 추가합니다.

// App.js
function App() {
  const observerElem = useRef(null)
...

여기에서 ref attribute에 전달된 observerElem 변수를 만들었습니다. 이를 통해 DOM이 로드될 때 위에서 만든 div 요소에 액세스할 수 있습니다. 우리는 코드에서 Intersection Observer로 div element를 전달하기 위해 이 작업을 수행합니다. 다음으로 useInfiniteQuery hook 뒤에 다음 코드 줄을 추가합니다.

// App.js
const handleObserver = useCallback((entries) => {
  const [target] = entries
  if(target.isIntersecting) {
    fetchNextPage()
  }
}, [fetchNextPage, hasNextPage])

useEffect(() => {
  const element = observerElem.current
  const option = { threshold: 0 }

  const observer = new IntersectionObserver(handleObserver, option);
  observer.observe(element)
  return () => observer.unobserve(element)
}, [fetchNextPage, hasNextPage, handleObserver])

위에서, IntersectionObserver에 전달되는 콜백인 handleObserver 함수를 만들었습니다. observer.observe(element)로 지정된 대상 요소가 뷰포트에 진입하면 fetchNextPage를 호출합니다.

이를 통해 앱에서 페이지 맨 아래로 스크롤할 때 새 데이터를 가져옵니다.

사용 가능한 데이터에 따라 fetching 제어

지금 당장은 가져올 데이터가 없고 앱에서 페이지 맨 아래로 스크롤하더라도 fetchNextPage가 계속 호출되어 더 많은 데이터를 가져오기 위해 API에 요청을 보냅니다. 이를 방지하려면 데이터가 남아 있지 않을 때 getNextPageParam에 false 값(undefined, 0 null, false)을 반환해야 합니다. 이렇게 하면 반환된 hasNextPage 속성은 데이터가 남아 있지 않을 때 false와 같을 것이며, 이를 사용하여 fetchNextPage 함수가 호출되는 시기를 제어할 것입니다.

이렇게 하려면 useInfiniteQuery hook의 getNextPageParam 옵션을 다음과 같이 수정합니다.

getNextPageParam: (lastPage, allPages) => {
  const nextPage = allPages.length + 1
  return lastPage.items.length !== 0 ? nextPage : undefined
}

위에서 우리는 마지막 fetch에서 데이터가 반환되었는지 여부에 따라 다음 페이지 매개변수 또는 undefined를 반환합니다.

이제 hasNextPagefalse일 때만 fetchNextPage를 호출하도록 handleObserver 함수를 수정해 보겠습니다.

// App.js
const handleObserver = useCallback((entries) => {
  const [target] = entries
  if(target.isIntersecting && hasNextPage) {
    fetchNextPage()
  }
}, [fetchNextPage, hasNextPage])

참고하기
Pagination and infinite scroll with React Query v3
React | Infinite Scroll 구현하기 (react-query, react-intersection-observer)
https://jforj.tistory.com/247


댓글 목록

이 분의 코드를 참고해서 작성했는데 좀 더 공부하고 수정해야겠다. 이건 Observer를 사용하지 않고 더 보기 버튼을 눌렀을 때 불러오게 작성한 코드!

import { BiDotsHorizontalRounded } from 'react-icons/bi';
import { useInfiniteQuery } from 'react-query';
import { getCommentsRequest } from '../../modules/board/api';
import { getDateText } from '../../utils';

export default function CommentList({ id }: { id: string }) {
  const fetchComments = (page: number) => getCommentsRequest(+id, page);

  const { data, fetchNextPage, isFetching, isFetchingNextPage, hasNextPage } =
    useInfiniteQuery(
      ['CommentList', id],
      ({ pageParam = 1 }) => fetchComments(pageParam),
      {
        // keepPreviousData: true,
        getNextPageParam: (lastPage, allPages) => {
          // console.log('getNextPageParam:', lastPage, allPages);
          const nextPage = allPages.length + 1;
          return lastPage.currentPage < lastPage.totalPage
            ? nextPage
            : undefined;
        },
      }
    );

  return (
    <div>
      <ul>
        {data?.pages.map((page) =>
          page.commentList.map((comment) => (
            <li key={comment.commentId} className="">
			  ...
            </li>
          ))
        )}
      </ul>
      {hasNextPage ? (
        <button
          className="font-medium m-1 text-sm md:text-base"
          onClick={fetchNextPage}
        >
          댓글 더 보기
        </button>
      ) : (
        <p className="font-medium text-gray-4 m-1 text-sm md:text-base">
          마지막 댓글입니다
        </p>
      )}
      <div>{isFetching && !isFetchingNextPage ? 'Fetching...' : null}</div>
    </div>
  );
}

hasNextPage로 마지막 페이지인지도 체크했는데 현재 페이지랑 마지막 페이지 위치도 표시하고 싶고(1/14) 그리고,,

백엔드님이 댓글 데이터 왕창 넣어주신 거로 테스트


좋아요 리스트

React Example: Load More Infinite Scroll
react-intersection-observer

import { useCallback, useEffect, useRef } from 'react';
import { useInfiniteQuery } from 'react-query';
import { useNavigate, useParams } from 'react-router-dom';
import { getLikesRequest } from '../../modules/board/api';
import { useInView } from 'react-intersection-observer';

export default function LikeList() {
  const navigate = useNavigate();
  const { id } = useParams<{ id: string }>();
  const { ref, inView } = useInView({
    threshold: 0,
  });

  const fetchLikes = (page: number) => getLikesRequest(+id, page);

  const { data, fetchNextPage, isFetching, isFetchingNextPage, hasNextPage } =
    useInfiniteQuery(
      ['Likes', { id }],
      ({ pageParam = 0 }) => fetchLikes(pageParam),
      {
        getNextPageParam: (lastPage, allPages) => {
          return lastPage.currentPage < lastPage.totalPage
            ? allPages.length + 1
            : undefined;
        },
      }
    );

  useEffect(() => {
    document.body.style.cssText = `
    position: fixed; 
    overflow-y: scroll;
    width: 100%;
    height: 100%;`;
    return () => {
      const scrollY = document.body.style.top;
      document.body.style.cssText = '';
      window.scrollTo(0, parseInt(scrollY || '0', 10) * -1);
    };
  }, []);

  useEffect(() => {
    if (inView) fetchNextPage();
  }, [inView]);

  return (
    <div
      onClick={(e) => {
        if (e.target === e.currentTarget) {
          navigate(`/board/${id}`);
        }
      }}
    >
      <div>
        <div>좋아요</div>
        <ul>
          {data?.pages.map((page) =>
            page.likeList.map((like) => (
              <li key={like.likeId}>...</li>
            ))
          )}
          <div ref={ref} className="m-1">
            {isFetching && isFetchingNextPage && hasNextPage
              ? '불러오는 중...'
              : null}
          </div>
        </ul>
      </div>
    </div>
  );
}

profile
꾸준히 자유롭게 즐겁게

0개의 댓글