나는 Axios 인스턴스를 만들어서 사용하고 있었고, AxiosRequestConfig의 params로 page와 pageSize를 넘겨줄 수 있게 API가 설계되어 있었다.
getArticles = async ({ page, pageSize }: { page: number; pageSize: number }) => {
const response = await instance.get<{ data: ArticleList }>(this.endpoint, {
params: {
page,
pageSize,
},
});
return response.data.data;
};
queryFn: (context: QueryFunctionContext) => Promise<TData>
queryFn은 기본적으로 context를 매개변수로 받는데, 이는 QueryFunctionContext로 여러가지를 가지지만 특히 pageParam을 갖고 있다. pageParam은 현재 페이지를 fetch하기 위해 필요한 아주 중요한 정보다. 이를 getArticles의 매개변수로 넘겨준다. 내가 받고 싶은 게시글이 몇 번째 페이지인지 알아야 하니까!!
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => TPageParam | undefined | null
이름만 봐도 알 수 있듯이 다음 페이지 파라미터를 받는 함수다. 매개변수로는 lastPage와 allPages가 주로 쓰이는데, lastPage에는 마지막으로 fetch한 페이지의 게시글 목록이 배열 형태로 담겨있다. allPages는 지금까지 받은 모든 데이터를 배열 형태로 가지고 있다. 예를 들어, 아래와 같다.
// page 1
[ item1, item2 ]
// page 2 (lastPage)
[ item3, item4 ]
// allPages
[[ item1, item2 ], [ item3, item4 ]]
그리고 여기서 return 되는 값이 위 pageParam으로 넘겨진다. 여기서 값이 undefined이나 null이면 다음 페이지가 없다는 의미이다.
export const useArticles = ({ pageSize }: { pageSize: number }) => {
return useSuspenseInfiniteQuery<
ArticleList,
AxiosError,
InfiniteData<ArticleList>,
typeof ARTICLES_KEY,
number
>({
queryKey: ARTICLES_KEY,
queryFn: ({ pageParam }) => api.getArticles({ page: pageParam, pageSize }),
initialPageParam: 0,
getNextPageParam: (lastPage, allPages) => {
if (lastPage.length < 10) return undefined;
return allPages.length;
},
});
};
allPages.length
가 곧, 다음 페이지 넘버이다.다음으로 해야 할 일은 스크롤이 특정 지점까지 내려왔을 때, 다음 페이지를 요청하는 것이다. 여러 작은 프로젝트를 거치면서 이때 사용하기 딱 좋은 게 IntersectionObserverAPI라는 생각이 들었다.
observe하는 ref에 데이터가 들어오고 나서 어떻게든 강제로 움직임을 일으키는 방법을 찾고자 했으나, 마땅한 해결책을 찾지 못하던 와중에 한 글을 발견하게 되었다. 이래서 기술문서가~
https://tech.kakaoenterprise.com/149
import { useCallback, useEffect, useRef } from 'react';
type IntersectHandler = (entry: IntersectionObserverEntry, observer: IntersectionObserver) => void;
const useIntersect = (onIntersect: IntersectHandler, options?: IntersectionObserverInit) => {
const ref = useRef<HTMLDivElement>(null);
const callback = useCallback(
(entries: IntersectionObserverEntry[], observer: IntersectionObserver) => {
entries.forEach((entry) => {
if (entry.isIntersecting) onIntersect(entry, observer);
});
},
[onIntersect]
);
useEffect(() => {
if (!ref.current) return;
const observer = new IntersectionObserver(callback, options);
observer.observe(ref.current);
return () => observer.disconnect();
}, [ref, options, callback]);
return ref;
};
export default useIntersect;
조금 더 자세히 살펴보자면,
// entry: 타겟과 root(parent) 엘리먼트 사이의 정보
// observer: 엘리먼트를 관찰 중인 observer 인스턴스 (observe, unobserve 관찰 할지 말지 결정)
type IntersectHandler = (entry: IntersectionObserverEntry, observer: IntersectionObserver) => void;
// 옵저버 인스턴스를 생성할 때 사용할 콜백 함수 생성
// 엘리먼트가 뷰포트에 들어올 때 onIntersect 함수 실행
const ref = useRef<HTMLDivElement>(null);
const callback = useCallback(
(entries: IntersectionObserverEntry[], observer: IntersectionObserver) => {
entries.forEach((entry) => {
if (entry.isIntersecting) onIntersect(entry, observer);
});
},
[onIntersect]
);
// ref가 null이 아니면 옵저버를 생성하고 해당 ref에 대한 observe를 실행
useEffect(() => {
if (!ref.current) return;
const observer = new IntersectionObserver(callback, options);
observer.observe(ref.current);
return () => observer.disconnect();
}, [ref, options, callback]);
https://ww8007-learn.tistory.com/6
위에서 만든 커스텀 훅을 아래와 같이 사용한다. 그러면 ref가 생성되고, useEffect 내부 로직이 실행되면서 observer가 생성되고, 관찰을 시작하게 된다. 해당 엘리먼트가 뷰포트에 들어왔을 때 기존의 observer는 끊고, 다음 페이지가 있고, fetch 하고있지 않을 때 다음 페이지를 fetch한다. 덕분에 처음 다음 페이지를 fetch할 때도 문제 없이 observer가 정상작동 하는 것을 볼 수 있다.
const ref = useIntersect(async (entry, observer) => {
observer.unobserve(entry.target);
if (hasNextPage && !isFetching) {
fetchNextPage();
}
});
return (
{/* ... */}
<div className={styles.observer} ref={ref} />
)
useRef
등 다양한 hooks에 익숙해지셔야 가능할 작업이긴 합니다.