pageParam: 다음 요청에 전달될 커서/페이지getNextPageParam(lastPage): 응답에서 다음 커서를 추출fetchNextPage(): 다음 페이지 불러오기 트리거(반환값)hasNextPage / isFetchingNextPage: 다음 페이지 존재/로딩 상태(반환값)data.pages: 페이지 배열(각 항목이 한 페이지 응답- 반환값)import { getMyActivitiesList } from "@/lib/api/myActivities";
import { useInfiniteQuery } from "@tanstack/react-query";
export const QK_MY_ACTIVITIES = ["MyActivities"] as const;
type UseMyActivitiesListProps = {
size?: number;
};
export function useMyActivitiesListInfinite({ size = 8 }: UseMyActivitiesListProps) {
return useInfiniteQuery({
queryKey: QK_MY_ACTIVITIES,
queryFn: ({ pageParam }: { pageParam: number | null }) =>
getMyActivitiesList({ size, cursorId: pageParam }),
initialPageParam: null, // 첫 페이지 커서
getNextPageParam: (lastPage) => lastPage.cursorId ?? null,
refetchOnWindowFocus: false,
});
}
initialPageParam: 첫 호출용 커서(null 등)getNextPageParam(lastPage): 응답에서 다음 커서를 반환 → 없으면 null“#target이라는 요소가 화면(뷰포트)에 10% 이상 보이면 ‘보이기 시작했다!’ 로그를 찍어줘.”
const el = document.querySelector("#target");
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
console.log("화면에 보이기 시작!");
} else {
console.log("화면 밖으로 사라짐!");
}
});
},
{
root: null, // 브라우저 뷰포트 기준
threshold: 0.1, // 요소가 10% 이상 보이면 콜백 실행
}
);
observer.observe(el);
root : 관찰 기준이 되는 스크롤 컨테이너. null이면 브라우저 뷰포트가 기준
rootMargin : 관찰 영역의 바깥쪽 여백(프리패치 버퍼). "400px 0px"이면 아래쪽 400px 앞당겨 감지
threshold : 요소가 얼마나 보였을 때 콜백을 호출할지(0~1 또는 배열). 0은 단 1픽셀만 보여도 트리거
"use client";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMemo, useRef, useEffect, useCallback } from "react";
import { toast } from "react-toastify";
// Icons
import EmptyList from "@/assets/svgs/empty_list.svg";
// UI
import { SideNavigationMenu } from "@/components/layout/SideNavigationMenu/SideNavigationMenu";
import { ActivityItem } from "@/components/my-activities/ActivityItem";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { ErrorView } from "@/components/ui/ErrorView/ErrorView";
// Query
import { useMyActivitiesListInfinite } from "@/lib/hooks/MyActivities/useMyActivitiesListInfinite";
const MyActivities = () => {
const router = useRouter();
const {
data,
isLoading,
isError,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
refetch,
isFetching,
} = useMyActivitiesListInfinite({ size: 8 });
// 1) 페이지 배열을 평탄화하여 단일 리스트로 사용
const list = useMemo(
() => data?.pages.flatMap((p) => p.activities) ?? [],
[data]
);
// 2) 리스트 끝에 둘 "관찰 지점" (센티널)
const sentinelRef = useRef<HTMLDivElement | null>(null);
// 3) 다음 페이지 로더 (에러 처리 포함)
const onLoadMore = useCallback(async () => {
try {
await fetchNextPage();
} catch {
toast.error("다음 페이지를 불러오지 못했어요. 다시 시도해 주세요.");
}
}, [fetchNextPage]);
// 4) IntersectionObserver 설정
useEffect(() => {
if (!sentinelRef.current) return;
const el = sentinelRef.current;
const io = new IntersectionObserver(
(entries) => {
const entry = entries[0];
// - sentinel이 보이고
// - 다음 페이지가 있으며
// - 이미 다음 페이지를 불러오는 중이 아니라면
if (entry.isIntersecting && hasNextPage && !isFetchingNextPage) {
onLoadMore();
}
},
{
root: null, // null이면 뷰포트 기준
rootMargin: "400px 0px", // 아래쪽 400px 앞당겨 프리패치
threshold: 0, // 1픽셀만 보여도 콜백
}
);
io.observe(el);
return () => io.disconnect(); // 메모리 누수 방지
}, [hasNextPage, isFetchingNextPage, onLoadMore]);
return (
<main className="pb-[200px] pt-[70px] px-[16px] md:px-[32px]">
<div className="mx-auto flex max-w-[1200px] w-full gap-x-[24px]">
<aside className="shrink-0 hidden md:block">
<SideNavigationMenu />
</aside>
<section className="flex flex-col gap-y-[24px] flex-1" aria-labelledby="my-activities-title">
<header className="flex justify-between">
<h3 id="my-activities-title" className="text-3xl text-black font-bold">
내 체험 관리
</h3>
<button
className="bg-nomadBlack rounded-[4px] py-[11px] px-[16px] text-white text-md md:text-lg font-bold"
onClick={() => router.push("/my-activities/registration")}
>
체험 등록하기
</button>
</header>
{isLoading ? (
<div className="flex justify-center items-center h-[400px]">
<Spinner size="56px" />
</div>
) : isError && !data ? (
<ErrorView
message="내 체험 관리를 불러오는 중 오류가 발생했어요."
refetch={refetch}
isFetching={isFetching}
/>
) : list.length > 0 ? (
<>
<ul id="reservation-list" className="flex flex-col gap-y-[24px]">
{list.map((item) => (
<li key={item.id}>
<Link href={`/activities/${item.id}`}>
<ActivityItem {...item} />
</Link>
</li>
))}
</ul>
{/* 5) 추가 로딩 상태 표시 */}
<div className="flex justify-center mt-[24px] min-h-[40px]">
{isFetchingNextPage && <Spinner size="35px" />}
</div>
{/* 6) 관찰 지점(Sentinel) — 이게 보이면 onLoadMore 호출 */}
<div ref={sentinelRef} className="h-1" />
</>
) : (
<div className="flex flex-col items-center gap-y-[12px] lg:gap-y-[20px] pt-[40px]">
<Image
src={EmptyList}
alt="내 체험 없음"
className="w-[200px] h-[200px] lg:w-[240px] lg:h-[240px]"
/>
<p className="text-2xl text-gray-800">아직 등록한 체험이 없어요</p>
</div>
)}
</section>
</div>
</main>
);
};
export default MyActivities;
fetchNextPage()rootMargin: "400px 0px"로 바닥 400px 전에 미리 로드hasNextPage && !isFetchingNextPagereturn () => io.disconnect()로 관찰 해제data.pages.flatMap((p) => p.activities)로 페이지 배열 합치기스크롤 컨테이너가 따로 있는 경우(예: 내부 스크롤 박스):
root를 그 컨테이너 요소로 설정하고, io.observe(sentinel)와 함께 컨테이너에 overflow: auto가 있어야 함const container = containerRef.current; // 스크롤 박스 const io = new IntersectionObserver(cb, { root: container, rootMargin: "300px 0px", threshold: 0 });
const list = data?.pages.flatMap(p => p.activities) ?? []
{
pages: [
{
activities: [
{ id: 1, title: "제주 감귤 따기" },
{ id: 2, title: "강릉 커피 로스팅" }
],
cursorId: 2
},
{
activities: [
{ id: 3, title: "서울 야경 사진 클래스" },
{ id: 4, title: "남해 요트 투어" }
],
cursorId: 4
}
],
pageParams: [null, 2]
}
data.activities{
"activities": [
{ "id": 1, "title": "제주 감귤 따기" },
{ "id": 2, "title": "강릉 커피 로스팅" }
],
"totalCount": 24
}