무한스크롤 구현하기 1 (Tanstack-Query)

foresec·2024년 6월 6일

Project

목록 보기
8/11

어떤 GET 요청을 했을 때, 한화면에 담기지도 않을 수많은 데이터를 꼭 한번에 받아올 필요는 없다

  • 오히려 필요 이상의 데이터를 한번에 받아오면 속도와 성능이 떨어지게 된다.

그러므로, Tanstack Query의 useInfiniteQuery를 활용해 무한스크롤을 구현하며 효율적으로 데이터를 가져와 렌더링될 수 있도록 해보자

useInfiniteQuery

https://tanstack.com/query/latest/docs/framework/react/reference/useInfiniteQuery

기본 형태

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

Option

먼저 필수옵션으로 3가지가 있다

  1. queryFn
queryFn: (context: QueryFunctionContext) => Promise<TData>

데이터를 요청하는 데 사용되며, Promise를 반환함

  1. initialPageParam
    첫번째 페이지를 가져올 때 사용될 기본 페이지 변수

  2. getNextPageParam

getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => TPageParam | undefined | null

새로운 데이터를 받았을때 호출되는 함수

  • 쿼리 함수의 마지막 변수로 전달될 단일 변수를 반환해야하며
  • 다음 페이지가 없을 경우 undefined나 null이 반환됨

그 외에도 여러가지 옵션이 있다

Returns

반환되는 속성은 기본적으로 useQuery훅과 동일하며 추가된 속성이 있음

  • fetchNextPage : 다음 페이지를 가져옴
  • fetchPreviousPage : 이전 페이지를 가져올 수 있음

등등 직접 써보면서 익혀보자


offset형식으로 무한스크롤을 구현해보자

https://developer.spotify.com/documentation/web-api/reference/search
를 확인해보면, page형식을 지원하는 부분이 없어 page형태로는 무한정 데이터를 받아올 수가 없는 구조였다.

그렇다면 offset(특정 위치부터 데이터를 가져오는데 쓰이는 매개변수)을 활용해서 가져오자!

useInfiniteQuery 활용

  const {
    data: searchResults,
    isLoading,
    isError,
    error,
    fetchNextPage,
  } = useInfiniteQuery({
    queryKey: ["searchInfinite", { searchVal: searchVal }],
    initialPageParam: 0,
    queryFn: ({ pageParam }) => handleSearchForInfinite(searchVal, pageParam),
    getNextPageParam: (lastPage, allPages, lastPageParam) => {
      let nextOffset = lastPageParam + ITEMS_PER_PAGE;
      return nextOffset;
    },
    enabled: !!searchVal.trim(),
  });

크게 queryKey로 고유한 값을 저장,
initialPageParam으로 첫번째로 받아올 페이지 변수를 설정,
getNextPageParam으로 offset형식을 구현했다.

getNextPageParam

여기서 return된 값이 pageParam으로 return된다
lastPage, allPages는 쓰지 않았고, lastPageParam에 고정된 값을 더하여 사용하여 offset을 update했다

Query Keys

queryKey: ["searchInfinite"],

처음에 단순히 이렇게 작성했더니 입력값이 변할때마다 쿼리가 새롭게 요청되질 않았다.

이를 해결하기 위해 찾아본 결과
https://tanstack.com/query/latest/docs/framework/react/guides/query-keys

쿼리 함수가 변수에 의존하는 경우 변수를 포함해야하며 쿼리키 에 입력한 값은 배열 형태로 값을 받는다

즉 기존의 값으로는 그냥 "searchInfinite"가 key로만 저장되어 searchVal(검색값)가 변해도 변화가 없었던 것이다

이 때, key를 작성할때 유의해야할 점은
이와 같이 객체 형태로 작성할 경우 동일한 key로 간주된다는 것이다

useQuery({ queryKey: ['todos', { status, page }], ... })
useQuery({ queryKey: ['todos', { page, status }], ...})
useQuery({ queryKey: ['todos', { page, status, other: undefined }], ... })

배열로 작성해야 고유한 key가 된다

useQuery({ queryKey: ['todos', status, page], ... })
useQuery({ queryKey: ['todos', page, status], ...})
useQuery({ queryKey: ['todos', undefined, page, status], ...})

아무튼, 결론적으로는 나름의 형식을 만들고 싶어 객체와 배열의 형태를 섞은 다음과 같이 작성했다.

queryKey: ["searchInfinite", { searchVal: searchVal }],

data가 더이상 없을때 검색 제한

이제 그다음으로 받아올 data가 더이상 없을 때 받아오지 못하도록 제한을 걸어햐했다.

fetchNextPage를 활용하면 된다.

  const nextDataClick = () => {
    fetchNextPage();  
  };

하지만....

발생한 문제점

hasNextPage라는 쿼리의 리턴값을 써서 다음에 받아올 값이 존재하는지 확인해보려고 했는데 무한정 true만 반환했다.

왜그런지 받아온 data형태를 뜯어봤을때 Spotify는 응답을 안해주는게 아니라 응답을 하는데 필요한 데이터가 담겨야할 배열에 빈배열을 돌려줬다

해결

그래서 hasNextPage사용을 접고 length로 판단했다.

일단 (임시)버튼의 노출부터 조건부로 제한하고

{searchResults?.pages[searchResults.pages.length - 1].tracks.items
              .length > 0 && <button onClick={nextDataClick}>NEXT</button>}

일단 필수적인건 아니지만 관련 함수도 함께 제한했다

  const nextDataClick = () => {
    const lastPage = searchResults?.pages[searchResults.pages.length - 1];
    if (lastPage && lastPage.tracks.items.length > 0) {
      fetchNextPage();
    }
  };

전체코드

export default function SearchPage() {
  const [searchVal, setSearchVal] = useState("");
  const ITEMS_PER_PAGE = 5;

  const handleSearchVal = (val: string) => {
    setSearchVal(val);
  };

  const handleSearchForInfinite = async (q: string, offset: number) => {
    const response = await getSearchResultAPI({
      q: q,
      type: ["track"],
      market: "KR",
      limit: 10,
      offset: offset,
    });
    return response;
  };

  const {
    data: searchResults,
    isLoading,
    isError,
    error,
    isSuccess,
    fetchNextPage,
    hasNextPage,
  } = useInfiniteQuery({
    queryKey: ["searchInfinite", { searchVal: searchVal }],
    initialPageParam: 0,
    queryFn: ({ pageParam }) => handleSearchForInfinite(searchVal, pageParam),
    getNextPageParam: (lastPage, allPages, lastPageParam) => {
      let nextOffset = lastPageParam + ITEMS_PER_PAGE;
      return nextOffset;
    },
    enabled: !!searchVal.trim(),
  });

  const nextDataClick = () => {
    const lastPage = searchResults?.pages[searchResults.pages.length - 1];
    if (lastPage && lastPage.tracks.items.length > 0) {
      fetchNextPage();
    }
  };

  return (
    <div css={searchPageWrapperCSS}>
      <SearchBar onSearch={handleSearchVal} />
      <div css={searchWrapperCSS}>
        {/* Loading */}
        {isLoading && <Spinner />}
        {/* empty */}
        {!searchResults && !isLoading && !isError && (
          <div css={titleCSS}>검색하지 않아서 기본형태</div>
        )}
        {/* ERROR */}
        {isError && (
          <div css={titleCSS}>{error.message}와 같은 에러가 발생했습니다.</div>
        )}
        {searchResults?.pages && (
          <div css={partWrapperCSS}>
            <div css={titleCSS}></div>
            {searchResults?.pages.map((page, pageIdx) => (
              <div key={pageIdx}>
                {page.tracks && (
                  <div>
                    {/* TRACK 데이터 */}
                    {page.tracks.items.length !== 0 ? (
                      <div css={trackListWrapperCSS}>
                        {page.tracks.items.map((item, idx) => (
                          <Track key={idx} track={item} />
                        ))}
                      </div>
                    ) : (
                      <div css={emtpyDescriptionCSS}>검색된 곡이 없습니다.</div>
                    )}
                  </div>
                )}
              </div>
            ))}
            {searchResults?.pages[searchResults.pages.length - 1].tracks.items
              .length > 0 && <button onClick={nextDataClick}>NEXT</button>}
          </div>
        )}
      </div>
    </div>
  );
}

다음에는 진짜 스크롤에 기반한 무한스크롤과 검색과정에서 계속 신경쓰였던 점을 해결하기 위해 디바운스를 적용해야한다.

profile
왼쪽 태그보다 시리즈 위주로 구분

0개의 댓글