이번에 (어느덧 한 달이 지난 시점이지만) 프로젝트 목록을 불러오는 로직을 작성하게 되었다.
Next.js의 App router을 이용하기로 해서, react-query를 쓸지 안쓸지 팀원분들과 이야기를 나누어보았다.
토론의 결과는 react-query를 사용하자는 것으로 귀결되어 무한스크롤 역시 react-query를 사용해서 구현했다.
구현 계획은 프로젝트 리스트의 가장 아랫부분에 div를 넣어두고, 이 요소가 보이는 순간 새로운 데이터를 패칭하는 것이다.
그럼 바로 로직을 작성해보자.
Intersection Observer 이용하기
사용하려는 컴포넌트에서 intersectioin observer 설정을 해도 되지만, ProjectList.tsx는 데이터를 받아서 UI로 보여주는 UI컴포넌트이자, 여러 곳에서 사용하는 공통 컴포넌트이다.
그렇기 때문에 이 곳에서 observer 설정을 하고 데이터를 패칭하면 데이터와의 결합도가 높아지고, 기존의 UI 컴포넌트라는 명료한 책임도 잃어버리게 된다고 생각했다.
현재의 컴포넌트는 UI 컴포넌트로서 적절하게 책임을 다하고 있기 때문에 최대한 특정 데이터, 특정 페이지에 존속되지 않도록 필요한 ref 등을 prop으로 주입시켜주도록 했다.
로직 분리 및 재사용을 위해서 useIntersectionObserver이라는 훅을 만들어서 적용을 해주었다.
interface intersectionObserverOption {
root?: HTMLElement;
rootMargin?: string;
threshold?: number;
}
export function useIntersectionObserver<T extends HTMLElement>({
root,
rootMargin,
threshold,
}: intersectionObserverOption) {
const targetRef = useRef<T>(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const option = { root: root, rootMargin: rootMargin, threshold: threshold };
const targetObserver = new IntersectionObserver(entries => {
const entry = entries[0];
if (entry.isIntersecting) {
setIsVisible(true);
} else {
setIsVisible(false);
}
}, option);
const currentTargetRef = targetRef.current;
if (currentTargetRef) {
targetObserver.observe(currentTargetRef);
}
return () => {
if (currentTargetRef) {
targetObserver.unobserve(currentTargetRef);
}
};
}, [root, rootMargin, threshold]);
return { isVisible, targetRef };
}
어떤 태그가 타겟이 될 줄 모르기 때문에, 일단 extends를 통해 HTML_ELMENT라는 것만 명시해두고 제네릭으로 타입을 받아주었다.
이전에 intersectionObserver을 처음 사용했을 때 훅을 만들었었는데, 이때는 타입 지정을 특정 요소 (HTMLDivElement)로 고정해주었던 것을 아주 조금 개선해보았다. (개미 똥만큼이지만)
무한 스크롤을 위한 커스텀 훅을 적용해보자
우선, 앞서 말했듯 프로젝트 리스트 컴포넌트는 데이터를 받아서 보여주는 공통 UI 컴포넌트였기 때문에 이 곳에서 intersectionObserver을 바로 정의하여 사용할 수 없었다.
-> 새로 받아오길 원하는 데이터는 사용하는 곳마다 다르기 때문
-> prop으로 패칭 함수까지 받으면 되지만, 그렇게 된다면 이 컴포넌트가 UI와 데이터 패칭까지 담당하여 기능들의 결합도가 높아진다고 생각했다. 그래서 우선 lastRef만 prop으로 받아주도록 했다.
interface ProjectListProp {
projectList: ProjectData[];
lastRef?: RefObject<HTMLDivElement>;
}
function ProjectList({ projectList, lastRef }: ProjectListProp) {
return (
<div className="relative grid grid-cols-4 gap-4">
{projectList && projectList.length > 0 ? (
projectList.map(project => (
<Link
href={`/project/${project.projectId}`}
className="flex cursor-pointer flex-col gap-2.5"
key={project.projectId}>
<ProjectCard project={project} />
<ProjectCardInfo
projectTitle={project.projectTitle}
projectSubDescription={project.introduction}
viewCount={formatViewCount(project.viewCount, 9999)}
/>
</Link>)
) : (
<EmptyCard />
)}
<div className="absolute bottom-0" ref={lastRef} />
</div>
);
}
export default ProjectList;
그리고 데이터를 패칭해주는 역할과 프로젝트 목록 및 개수를 보여주는 상위 컴포넌트에서 intersectionObserver의 타겟과 데이터 패칭을 담당하도록 하려고 한다.
export type MyPageProjectListType = "myProject" | "wishProject";
function MypageProjectSection({ isMyPage, projectType }: { isMyPage: boolean; projectType: MyPageProjectListType }) {
const { targetRef: lastCardRef, isVisible } = useIntersectionObserver<HTMLDivElement>({ threshold: 1 });
/** react-query를 이용한 무한 스크롤 데이터 패칭 로직이 들어갈자리 */
return (
<section>
<h3 className="mb-4 text-lg font-semibold leading-relaxed text-gray-900">
{listTitle(isMyPage, projectType)}
<span className="ml-2.5">({data.customPageable.totalElements})</span>
</h3>
<ProjectList projectList={/**패칭된 데이터가 들어갈 자리*/} lastRef={lastCardRef} />
</section>
);
}
export default MypageProjectSection;
그렇다면 이제는 , react-query를 사용해서 데이터를 패칭해보자
function MypageProjectSection({ isMyPage, projectType }: { isMyPage: boolean; projectType: MyPageProjectListType }) {
const { targetRef: lastCardRef, isVisible } = useIntersectionObserver<HTMLDivElement>({ threshold: 1 });
// react-query의 useInfiniteQuery를 이용한 무한 스크롤 구현
const { data, fetchNextPage } = useInfiniteQuery({
queryKey: ["projectList", projectType],
queryFn: ({ pageParam = 1 }) =>
projectListAPI.getMyProjectList({ page: pageParam as number, size: 8 }, projectType),
initialPageParam: 1,
getNextPageParam: lastPage => {
const { customPageable } = lastPage;
if (customPageable.hasNext) {
return customPageable.page + 1;
}
return undefined;
},
});
useEffect(() => {
if (isVisible) {
fetchNextPage();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isVisible]);
return (
<section>
<h3 className="mb-4 text-lg font-semibold leading-relaxed text-gray-900">
{listTitle(isMyPage, projectType)}
<span className="ml-2.5">({data?.pages[0].customPageable.totalElements})</span>
</h3>
<ProjectList projectList={data?.pages} lastRef={lastCardRef} />
</section>
);
}
앞서 설정한 ref가 보일 때마다 fetchNextPage()를 통해 다음 데이터를 불러오도록 했다.
이로써 내가 원하는 무한 스크롤이 구현 되었다.
사실 한 달 전쯤 1차로 마무리가 된 프로젝트였기 때문에 블로깅을 진작에 했어야 했지만, 이번에 무한 스크롤 관련 트러블 슈팅을 진행하면서 다시 한 번 로직을 복기할 겸 포스팅하게 되었다.
Next.js의 App router을 쓰면서 스켈레톤 UI를 구현하기 위해서 react-query의 프리패칭을 쓸 지 isPending을 쓸지 정말 정신이 없었지만 이 부분도 추후에 포스팅하게 될 것 같다.
그럼 오늘은 이만~
안뇽!
'🗂️ 개발 이모저모' 카테고리의 다른 글
내 이미지 어디갔어 !? (배포환경에서 svg 이미지가 로드되지 않는 이유) (5) | 2024.09.12 |
---|---|
[타임세이버] query-key 줍다 지친 사람 여기 여기 붙어라 (with. query-key-factory) (0) | 2024.08.22 |
[FeedB] invalidateQueries가 동작하지 않았던 이유 (with. NextJS) (0) | 2024.07.05 |
[FeedB] 기획 소개 및 컨벤션 (feat.구현계획) (0) | 2024.06.17 |
[React] key값으로 index를 쓰지 말라고? with.UUID (4) | 2024.06.04 |