드디어 기획자님이 꼭 원하시던 무한 스크롤 구현을 끝냈다. 후기를 먼저 말하자면, 역시 강의를 보는 것과 직접 하는 것은 큰 차이가 있다. 제로초님의 next.14 강의중 react-query를 이용한 무한스크롤 구현을 봤을 때는 굉장히 쉽고 간단하게 느껴졌다. 그래서 직접 무한스크롤을 구현하려다 애를 먹고 있던 중 라이브러리를 사용하게 되었다. 하지만 강의 프로젝트의 세팅과 내 프로젝트의 세팅은 몇가지 차이가 있었다.
1. 강의는 app router를 사용하지만, 나는 pages router를 사용한다.
2. 강의의 무한 스크롤용 데이터는 쿼리로 cursor=number를 받아 몇 개씩 받아올지 넘기지만, 내 백엔드 api의 url은 쿼리로 base=number&limit=number 즉, base~limit 사이 데이터만 받아올 수 있다. (필요한 쿼리의 갯수 차이)
위의 차이를 적용한 코드는 다음과 같다.
우선, react-query 기본세팅을 해준다.
Provider를 따로 모듈화하고,
/lib/react_query/RQProvider.tsx
import React, { useState } from "react";
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
type Props = {
children: React.ReactNode;
};
function RQProvider({ children }: Props) {
const [client] = useState(
new QueryClient({
defaultOptions: {
// react-query 전역 설정
queries: {
// 탭 전환시
refetchOnWindowFocus: false,
// 컴포넌트가 언마운트됐다가 다시 마운트 되는 순간
retryOnMount: true,
// 인터넷 연결이 끊겼다가 재접속 되는 순간
refetchOnReconnect: false,
// 데이터를 가져올 때, 실패했다면 몇번 더 시도 할지
retry: false,
},
},
}),
);
return (
<QueryClientProvider client={client}>
{children}
<ReactQueryDevtools
initialIsOpen={process.env.NEXT_PUBLIC_MODE === "local"}
/>
</QueryClientProvider>
);
}
export default RQProvider;
/.env.local에 변수를 넣는다.
NEXT_PUBLIC_MODE=local
/pages/_app.tsx 에 기본 셋팅을 한다.
위에서 만들어 놓은 RQProvider로 데이터가 사용되는 부분을 감싸고 HydrationBoundary로 hydrate세팅을 한다.
여기서, hydrate란?
'서버에서 받아온 데이터를 클라이언트에서 형식을 맞춰서 물려받는 것'을 말한다.
import "@styles/globals.css";
import "@styles/quill.css";
import type { AppProps } from "next/app";
import { Noto_Sans, Source_Serif_4 } from "next/font/google";
import CssBaseline from "@mui/material/CssBaseline";
import SantiagoLayout from "@components/layout/SantiagoLayout";
import Head from "next/head";
import RQProvider from "lib/react_query/RQProvider";
import { HydrationBoundary, QueryClient, dehydrate } from "@tanstack/react-query";
(...)
export default function App({ Component, pageProps }: AppProps) {
const queryClient = new QueryClient();
const dehydratedState = dehydrate(queryClient)
return (
<main className={cls(notoSans.className, sourceSerif.variable)}>
<CssBaseline />
<RQProvider>
<HydrationBoundary state={dehydratedState}>
<SantiagoLayout>
<Head>
<title>Santiago</title>
<meta
property="og:title"
content="Santiago"
key="title"
/>
<link rel="icon" href="/images/favicon.svg" />
</Head>
<Component {...pageProps} />
</SantiagoLayout>
</HydrationBoundary>
</RQProvider>
</main>
);
}
여기까지는 얼마 안걸렸다 ㅋㅋㅜㅜ
이 기능을 구현하면서 두 가지의 트러블슈팅이 있었다.
첫번째, 나는 아래의 url로 2개의 파라미터를 넘겨줘야 하고 9개씩 데이터가 날라오기를 원했다.
이 부분은 startPage 와 endPage에 pageParam을 이용해서 해결했다. 이때, pageParam은 0,1,2,... 와 같은 숫자로 날라온다.
import { SantiagoGet } from "lib/fetchData";
type Props = {
pageParam: number;
};
export async function getMagazineList({ pageParam }: Props) {
const startPage = pageParam * 9; // 0,9,18
const endPage = (pageParam + 1) * 9 -1; // 8, 17, 26
const result = await SantiagoGet(
`magazines?query_type=hot&base=${startPage}&limit=${endPage}`,
);
return result;
}
두번째, 그런데 문제는 날라온 데이터가 마지막 데이터인지 아닌지 확인할 방법이 없었다.
예를 들어, 총 50개의 데이터가 있다 치자. 그럼 startPage에 51이상의 숫자가 들어갈 경우 무한 스크롤을 멈춰야한다. 그렇게 하기 위해서는 hasNextPage가 null | undefined 를 받아 false가 되어야했다. tanstack-dev-tools를 확인해보면 총 데이터를 넘어가는 숫자를 요청했을 경우, data가 빈 배열로 넘어오는 것을 확인할 수 있었다. 그래서 삼항연산자를 이용해 마지막 페이지의 데이터의 길이가 0일 경우, undefined를 return해서 hasNextPage값이 false가 되도록 했다.
이렇게 하면, 아래의 useEffect부분에서 hasNextPage를 통과하지 못하기 때문에 fetchNextPage()가 실행되지 않는다.
// tanstack-query
(...)
const { data, fetchNextPage, hasNextPage, isFetching } = useInfiniteQuery({
queryKey: ["magazineList", "magazines"],
queryFn: getMagazineList,
initialPageParam: 0, // [[1,2,3,4,5],[6,7,8,9,10]] 2차원배열로 들어옴
getNextPageParam: (lastPage, pages) => {
return lastPage.data.length === 0 ? undefined : pages.length;
},
staleTime: 60 * 1000, // fresh -> stale, 5분이라는 기준
gcTime: 300 * 1000,
});
// react-intersection-observer
const { ref, inView } = useInView({
threshold: 0.9,
delay: 0,
});
useEffect(() => {
// 화면에 밑에 ref부분이 보이면
if (inView) {
!isFetching && hasNextPage && fetchNextPage();
}
}, [inView, isFetching, hasNextPage, fetchNextPage]);
(...)
전체코드는 아래와 같다.
import React, { Fragment, useEffect, useState } from "react";
import tw from "twin.macro";
import Magazine from "./Magazine";
import { useInfiniteQuery } from "@tanstack/react-query";
import { getMagazineList } from "lib/react_query/getMagazineList";
import { useInView } from "react-intersection-observer";
const Magazines = () => {
const { data, fetchNextPage, hasNextPage, isFetching } = useInfiniteQuery({
queryKey: ["magazineList", "magazines"],
queryFn: getMagazineList,
initialPageParam: 0, // [[1,2,3,4,5],[6,7,8,9,10]] 2차원배열로 들어옴
// 백엔드에 마지막 글인경우, nextCursor가 -1로 나오도록 하기
getNextPageParam: (lastPage, pages) => {
return lastPage.data.length === 0 ? undefined : pages.length;
},
staleTime: 60 * 1000, // fresh -> stale, 5분이라는 기준
gcTime: 300 * 1000,
});
const { ref, inView } = useInView({
threshold: 0.9,
delay: 0,
});
useEffect(() => {
// 화면에 밑에 ref부분이 보이면
if (inView) {
!isFetching && hasNextPage && fetchNextPage();
}
}, [inView, isFetching, hasNextPage, fetchNextPage]);
return (
<>
<div tw="self-start w-full grid grid-cols-3 gap-10 pr-8">
{data?.pages.map((group, i) => (
<Fragment key={i}>
{group.data.map((item: MagazineProps) => (
<Magazine key={item.id} item={item} />
))}
</Fragment>
))}
// 아래 코드로 observer가 인식할 수 있다.
<div ref={ref} style={{ height: 50 }} />
</div>
</>
);
};
export default Magazines;
다음에 또 무한스크롤이 필요하다면,
1. 백엔드와 초반에 api 주소에 cursor을 포함되어 있는지 확인할 것 같다.
2. 처음 명세서를 작성할 때, 한 api의 역할을 좁혀야 함을 어필할 것 같다.
'라이브러리' 카테고리의 다른 글
Auth.js(Next-Auth)의 useSession vs getSession (0) | 2024.07.13 |
---|---|
Avatar 컴포넌트 안에서 이미지 비율 안깨지게 하기 with Shadcn/ui (0) | 2024.07.08 |
요즘 Redux 대신 React-Query(tanstack-query)가 대세인 이유 (0) | 2024.03.04 |
티스토리 hELLO 스킨 + overstackflow-dark highlight 스킨 적용기 | 라이트 모드일 때 바뀌는 폰트 컬러 수정 (0) | 2024.02.27 |