기존 프로젝트에서는 무한스크롤을 구현하면서 특정 타깃이 감지되면 query param에 page값을 증가시켜서 api 요청하고, issue list를 상태로 관리하여 새로 패칭되는 issue list를 상태에 계속 붙여주었다.
이번에는 react-query의 useInfiniteQuery 함수를 사용하여 구현해보자.
react-query 공식문서 - useInfiniteQuery
useInfiniteQuery
...result와 ...options 부분의 값은 useQuery 와 같다.
const {
fetchNextPage,
fetchPreviousPage,
hasNextPage,
hasPreviousPage,
isFetchingNextPage,
isFetchingPreviousPage,
...result
} = useInfiniteQuery(queryKey, ({ pageParam = 1 }) => fetchPage(pageParam), {
...options,
getNextPageParam: (lastPage, allPages) => lastPage.nextCursor,
getPreviousPageParam: (firstPage, allPages) => firstPage.prevCursor,
})
Options
queryFn: (context: QueryFunctionContext) => Promise<TData>
데이터 요청하는데 사용하는 함수
context는 queryKey와 pageParam를 포함한다.
pageParam은 getXXXPageParam 함수들을 사용하기 위한 값이다.
getXXXPageParam: (lastPage, allPages) => unknown | undefined
다음 페이지 혹은 이전 페이지 데이터 요청 시 필요한 pageParam을 설정하는 함수
Returns
data
useQuery와 달리 data는 data.pages: TData[] 와 data.pageParams: unknown[] 를 반환한다.
pageParams는 각 페이지의 파라미터 값을 가지고 있고, data는 각 요청마다의 data를 배열로 가지고있다.
fetchNextPage: (options?: FetchNextPageOptions) => Promise<UseInfiniteQueryResult>
다음 페이지의 결과를 반환하는 함수
options.pageParam : getNextPageParam 함수를 사용하지 않고, 수동으로 pageParam을 설정할 수 있다.
options.cancelRefetch : true로 설정하면 fetchNextPage를 반복적으로 호출하면 이전 호출의 해결 여부와 관계없이 매번 fetchPage를 호출하고, false로 설정하면 첫 번째 호출이 해결될 때까지 아무런 영향을 미치지 않는다. 기본값은 true
fetchPreviousPage: (options?: FetchPreviousPageOptions) => Promise<UseInfiniteQueryResult>
이전 페이지의 결과를 반환하는 함수
options의 동작은 위와 동일하다.
hasNextPage
다음 페이지가 있는지에 대한 결과를 반환.
getNextPageParam 의 옵션을 통해 알 수 있다.
hasPreviousPage
이전 페이지가 있는지에 대한 결과를 반환.
getPreviousPageParam 의 옵션을 통해 알 수 있다.
isFetchngXXX
다음 또는 이전 페이지 fetch가 완료되었는지에 대한 결과를 반환
무한 스크롤 구현
우선 useInfiniteQuery를 적용해보자.
export const getIssueList = async (
owner: string,
repo: string,
params?: { per_page?: number; page?: number },
) => {
const response = await githubAPI.get(`/repos/${owner}/${repo}/issues`, {
params: params,
});
return response.data;
};
const { data, fetchNextPage, status } = useInfiniteQuery({
queryKey: ["issue_list",owner, repo],
queryFn: ({ pageParam = 1 }) =>
getIssueList(owner, repo, { page: pageParam, per_page: 5 }),
getNextPageParam: (lastPage, allPosts) => {
return allPosts.length + 1;
},
refetchOnWindowFocus: false,
});
보통 무한스크롤이나 페이지내이션을 구현할 때 몇 번째 페이지인지를 상태로 관리하는데, useInfiniteQuery를 사용하여 무한스크롤을 구현할때에는 api요청을 위해 page 상태를 관리할 필요가 없다.
queryFn의 매개변수 pageParam을 1로 설정하고 getNextPageParam함수를 통해 현재 불러온 배열 갯수를 사용하여 다음 페이지를 요청할 수 있다.
lastPage는 가장 최근에 불러온 page의 데이터를 담고있다.
useInfiniteScroll Hook
import { useEffect, useState } from 'react';
import throttle from '../lib/utils/throttle';
interface Options {
root?: Element | Document;
rootMargin?: string;
threshold?: number | number[];
}
interface Props {
fetchNextPage: () => void;
options?: Options;
}
function useInfiniteScroll({ fetchNextPage, options }: Props) {
const [target, setTarget] = useState<HTMLDivElement | null | undefined>(null);
const observerCallback: IntersectionObserverCallback = throttle((entries) => {
if (entries[0].isIntersecting) {
fetchNextPage();
}
});
useEffect(() => {
if (!target) return;
const observer = new IntersectionObserver(observerCallback, options);
observer.observe(target);
return () => observer.unobserve(target);
}, [observerCallback, options, target]);
return { setTarget };
}
export default useInfiniteScroll;
우선 props로 다음 데이터를 가져오기위한 함수와 IntersectionObserver의 옵션 값을 받는다.
그리고 교체될 요소를 지정하기위한 target을 상태로 관리한다.
observerCallback은 IntersectionObserver의 콜백함수로, 교차 상태를 체크할 때 fetchNextPage함수를 호출하는 역할을 한다. 이 때 빈번한 스크롤 이벤트로인한 성능저하를 막기위해 throttle함수를 사용하여 적절한 시간 간격으로 콜백함수가 실행되도록해줬다.
setTarget을 통해서 target의 값이 변경되면, 새로운 IntersectionObserver를 생성하고, 해당 타깃을 관찰한다.
setTarget은 무한 스크롤이 필요한 부분에 div요소를 만든 후 ref값에 넣어주면된다.
target을 useRef를 사용해서 구현해도 되지만, useState를 사용해서 구현했다.
만약 DOM 요소에 직접 접근해야하고 이전에 로딩한 데이터나 스크롤 관련 정보를 유지하려면 useRef로 구현하는게 좋고,
상태 변경에 따른 렌더링이 필요하다면 useState를 사용하여 구현하는게 좋다.
새로운 데이터가 오면 그 데이터를 기존의 issues 아래에 다음 issues를 붙여줘야하니, 새로 렌더링이 일어난다.
그래서 useState를 사용하는게 맞지 않을까? 라는 생각으로 만들긴했는데 ... 사실 확 와닿지는 않는다. 어떤게 더 좋은지....
이에 대해 알려주실분이 계시다면 댓글로 좀 알려주세요!
다음으로 throttle 함수에 대해 알아보자.
const throttle = (handler: (...args: any[]) => void, timeout = 300) => {
let lastInvokeTime: number;
let timer: ReturnType<typeof setTimeout>;
return function (this: any, ...args: any[]) {
const currentTime = Date.now();
if (currentTime - lastInvokeTime >= timeout) {
handler.apply(this, args);
lastInvokeTime = currentTime;
} else {
clearTimeout(timer);
timer = setTimeout(
() => {
handler.apply(this, args);
lastInvokeTime = Date.now();
},
timeout - (currentTime - lastInvokeTime),
);
}
};
};
export default throttle;
우선 throttle 함수는 handler 함수와, timeout 값을 인자로 받는다.
클로저를 사용하여 구현했는데, throttle 함수의 내부 함수가 클로저를 형성하며, 외부 함수의 변수(lastInvokeTime와 timer)에 접근하여 관리하고 사용하는 방식이다.
lastInvokeTime은 마지막으로 함수가 호출된 시점을 기록하는 변수이고, timer는 setTimeout함수의 반환값을 저장하는 변수이다.
내부함수는 실제로 throttling된 함수 호출을 수행한다.
지정된 timeout보다 현재 시간과 가장 마지막으로 호출된 시점과 차이가 크다면 그 시점에 handler.apply(this, args)를 통해서 함수를 바로 호출한다.
apply메소드는 this 컨텍스트를 설정하면서 함수를 실행시킬 수 있다. 이 메소드를 사용하여 handler 함수를 호출하면서 this 컨텍스트를 그대로 유지하고, args에 담긴 값들을 handler함수에 전달한다.
즉, handler.apply(this, args)를 사용하여 원본 함수를 호출함으로써, 스로틀링된 호출이 발생할 때마다 원본 함수의 동작이 수행되게 되는 것이다.
만약 기준 시간보다 작으면, 기존의 timeout을 clearTimeout을 통해 제거하고 새로운 timeout을 설정한다. 그런다음 timeout이 끝난다음 함수를 호출하고, 최근 호출 시간을 업데이트 해준다.
useInfiniteScroll 훅과 throttle 함수를 적용해보자.
interface IssueListProps {
owner: string;
repo: string;
}
const IssueList = ({ owner, repo }: IssueListProps) => {
const { data, fetchNextPage, status } = useInfiniteQuery({
queryKey: [owner, repo],
queryFn: ({ pageParam = 1 }) =>
getIssueList(owner, repo, { page: pageParam, per_page: 5 }),
getNextPageParam: (lastPage, allPosts) => {
return allPosts.length + 1;
},
refetchOnWindowFocus: false,
});
const { setTarget } = useInfiniteScroll({ fetchNextPage });
return (
<IssueListStyle>
{data &&
data.pages.map((page) =>
page.map((item: Issue) => <IssueItem key={item.id} issue={item} />),
)}
<div ref={setTarget} />
</IssueListStyle>
);
};
export default IssueList;
useInfiniteQuery, useInfiniteScroll, throttle 을 사용한 무한스크롤 구현 끝!