✔️ 1. root
✔️ 2. rootMargin
위 예시는 뷰포트가 기준(root: null)이고, rootMargin을 '0px 0px -50px 0px'으로 설정하여 아래쪽 50px 마진을 추가했습니다. 이 설정은 대상 요소가 완전히 뷰포트에 진입하기 50px 전에 콜백이 호출되도록 합니다. 이렇게 하면 요소가 화면에 완전히 나타나기 전에 콜백을 실행할 수 있습니다.
const observer = new IntersectionObserver(callback, {
root: null,
rootMargin: '0px 0px -50px 0px', // 아래쪽 마진을 -50px로 설정
threshold: 1.0,
});
✔️ 3. threshold
threshold는 요소가 루트(뷰포트 또는 지정한 루트 요소) 내에서 몇 퍼센트 보일 때 콜백을 호출할지를 정의합니다. 이 값은 0.0에서 1.0 사이의 숫자이거나 배열일 수 있습니다.
0: 대상 요소가 한 픽셀이라도 루트 요소 내에 들어오면 콜백이 호출됩니다.
1.0: 대상 요소가 완전히 루트 요소 안에 들어와야 콜백이 호출됩니다.
만약 threshold: 0.5를 하게된다면 요소가 “반 이상 보여야” entry.isIntersecting === true가 됩니다.
무한스크롤을 적용하는 컴포넌트의 뷰포트가 들어 요소가 반 이하로 보이면 isIntersecting이 곧바로 false가 되어 콜백이 호출되지 않게되는 이슈가 발생하게되므로, 해당 옵션을 주의해서 사용해야겠습니다.
import { useCallback, useEffect, useRef } from "react";
interface InfiniteScrollOptions {
root?: HTMLElement | null;
rootMargin?: string;
threshold?: number;
}
type UseInfiniteScrollHook = <T extends HTMLElement>(
callback: () => void,
hasMore: boolean,
options?: InfiniteScrollOptions,
) => React.RefObject<T>;
export const useInfiniteScroll: UseInfiniteScrollHook = <T extends HTMLElement>(
callback: () => void,
hasMore: boolean,
options: InfiniteScrollOptions = {
root: null,
rootMargin: "0px",
threshold: 1.0,
},
) => {
const observerRef = useRef<T>(null);
const observerCallback = useCallback(
(entries: IntersectionObserverEntry[]) => {
const [entry] = entries;
if (entry.isIntersecting && hasMore) {
callback();
}
},
[callback, hasMore],
);
useEffect(() => {
const target = observerRef.current;
if (!target) return;
const observer = new IntersectionObserver(observerCallback, options);
observer.observe(target);
return () => {
if (target) observer.unobserve(target);
};
}, [observerCallback, options]);
return observerRef;
};
//...
// useInfiniteScroll 훅 사용, fetchMoreData 콜백과 hasMore를 전달
const loaderRef = useInfiniteScroll<HTMLDivElement>(fetchMoreData, hasMore);
return (
<div>
<ul>
{items.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
{/* 스크롤이 끝에 도달할 때 이 div가 관찰됨 */}
<div ref={loaderRef} style={{ height: '100px', background: 'lightgray' }}>
{hasMore ? 'Loading more...' : 'No more items'}
</div>
</div>
import React, { useState, useEffect, useRef } from 'react';
const InfiniteScrollObserver = () => {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const observer = useRef(null);
const lastElementRef = useRef(null);
const fetchData = async () => {
// API 호출 또는 다른 방법으로 데이터를 가져옴
const response = await fetch(`https://api.example.com/data?page=${page}`);
const newData = await response.json();
setData((prevData) => [...prevData, ...newData]);
setLoading(false);
};
const handleIntersect = (entries) => {
if (entries[0].isIntersecting) {
setPage((prevPage) => prevPage + 1);
setLoading(true);
}
};
useEffect(() => {
fetchData();
}, [page]);
useEffect(() => {
if (lastElementRef.current) {
observer.current = new IntersectionObserver(handleIntersect, {
root: null,
rootMargin: '0px',
threshold: 1.0,
});
observer.current.observe(lastElementRef.current);
}
return () => {
if (observer.current) {
observer.current.disconnect();
}
};
}, [lastElementRef]);
return (
<div>
{/* 데이터 표시 */}
{data.map((item, index) => (
<div key={index}>{item}</div>
))}
{/* 로딩 표시 */}
{loading && <p>Loading...</p>}
{/* IntersectionObserver를 사용한 무한 스크롤을 위한 ref */}
<div ref={lastElementRef} style={{ height: '10px' }}></div>
</div>
);
};
export default InfiniteScrollObserver;
useInfiniteQuery() 옵션 정리| 옵션명 | 설명 |
|---|---|
queryKey | 캐싱을 위한 키 (배열 형태로 설정 가능) |
queryFn | 데이터를 가져오는 함수 (fetcher) |
getNextPageParam | 다음 페이지의 pageParam을 결정하는 함수 |
initialPageParam | 초기에 사용할 pageParam (기본값: undefined) |
staleTime | 데이터를 신선하게 유지하는 시간 |
cacheTime | 데이터를 메모리에 유지하는 시간 |
enabled | false이면 쿼리를 자동으로 실행하지 않음 |
import { useInfiniteQuery } from "@tanstack/react-query";
import { useRef, useEffect } from "react";
const useInfiniteScroll = ({ queryKey, queryFn, getNextPageParam }) => {
// data,
// fetchNextPage:다음 페이지 데이터를 불러오는 함수
// hasNextPage:다음 페이지가 있는지 여부 (true | false)
// isFetchingNextPage: 다음 페이지를 불러오는 중인지 여부
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
queryKey,
queryFn,
getNextPageParam,
initialPageParam: 1, // 초기 페이지
});
const observerRef = useRef(null);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage) {
fetchNextPage();
}
},
{ threshold: 1.0 },
);
if (observerRef.current) {
observer.observe(observerRef.current);
}
return () => observer.disconnect();
}, [hasNextPage, fetchNextPage]);
return {
data,
isFetchingNextPage,
observerRef,
};
};
export default useInfiniteScroll;
import React from "react";
import useInfiniteScrollQuery from "@/services/hooks/useInfiniteScrollQuery";
// API 호출 함수 (React Query)
const fetchPosts = async ({ pageParam = 1 }) => {};
const PostList = () => {
const { data, isFetchingNextPage, observerRef } = useInfiniteScrollQuery({
queryKey: ["posts"],
queryFn: fetchPosts,
getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined,
});
return (
<div>
{data?.pages.map((page, i) => (
<div key={i}>
{/*{page.items.map((post) => (*/}
{/* <p key={post.id}>{post.title}</p>*/}
{/*))}*/}
</div>
))}
<div
ref={observerRef}
style={{ height: "20px", background: "transparent" }}
/>
{isFetchingNextPage && <p>Loading more...</p>}
</div>
);
};
export default PostList;
여기서 의문! 그럼 다음 페이지는 누가 정해주는거야?
내부적으로 다음페이지가 어떤건지 알수잇는 라이브러리가 있는건가???
react-query는 서버에 "마지막 페이지냐?"고 직접 묻지 않는다✔️ useInfiniteQuery()는 서버가 응답하는 데이터를 기반으로 getNextPageParam()에서 직접 판단
✔️ 즉, react-query 자체는 "이게 마지막 페이지냐?"라고 서버에 묻는 요청을 보내지 않음
✔️ 대신 서버가 반환하는 데이터(nextPage 등)를 보고 판단
nextPage는 누가 제공해야 할까?react-query가 nextPage를 예측하는 것이 아님!nextPage 정보를 제공해야 함!nextPage를 반환하지 않으면, 클라이언트는 "마지막 페이지인지" 판단할 방법이 없음서버가 nextPage 값을 응답에 포함해야 함
{
"items": [ { "id": 5 }, { "id": 6 } ],
"nextPage": null // 서버가 마지막 페이지면 null 또는 undefined 반환
}
✔️ 서버가 마지막 페이지면 nextPage를 null, false, undefined로 반환해야 react-query가 판단 가능!
nextPage를 제공하지 않는다면?nextPage 없이 마지막 페이지를 감지하는 방법
✔️ 서버가 nextPage를 제공하지 않는다면, 다른 방식으로 마지막 페이지를 감지해야 함
✔️ 예를 들어, items.length === 0이면 마지막 페이지로 간주하는 방식 사용 가능
예제
const getNextPageParam = (lastPage) => {
return lastPage.items.length > 0 ? lastPage.nextPage ?? undefined : undefined;
};
✔️ 서버가 nextPage를 주지 않아도, 데이터가 없으면 마지막 페이지로 판단 가능
useInfiniteQuery의 getNextPageParam 함수에서 마지막 페이지일 때 false를 반환하도록 했음.
하지만 API 호출 시 false가 pageParam으로 전달되어, 내부적으로 hasNextPage가 여전히 true로 판단되어 추가 호출이 발생함.
분석 및 제안:
React Query는 getNextPageParam 함수에서 undefined를 반환해야 “더 이상 페이지가 없음”으로 인식함. false는 값이 존재하는 것으로 판단되어, hasNextPage가 true로 남게 됨.
추가로, queryKey의 안정성이나 API 응답 값 등도 함께 점검해봤지만, 문제의 근본 원인은 false를 반환한 점.
해결:
getNextPageParam 함수에서 마지막 페이지에 도달했을 때 false 대신 undefined를 반환하도록 수정함. 그 결과, hasNextPage가 올바르게 false로 설정되고 더 이상의 API 호출이 중단됨.
결국, false 대신 undefined를 반환해야 React Query가 더 이상 다음 페이지가 없음을 인식하여 문제가 해결된 것.
나는 useInfiniteQuery를 사용해서 데이터를 가져온뒤, 테이블에 넣어서 관리를 해야한다.
테이블에는 input, selectbox 등의 수정해야하는 부분들이 있다.
그래서 아래의 코드에서 data를 가져오고 난뒤, store에 그 데이터 값을 저장하여서
변경되는 데이터들을 관리하려고 하였다.
근데 아래의 모양 처럼
useInfiniteQuery의 return Data에는 아래와같이 나온다.
근데 이 데이터들을 가져와서 store에 넣으려고하니,
내가 store에서 변경했던 데이터들이 전부다 유실되게 된다.
왜냐면 Pages안에 있는 데이터들을 전부 다 넣어주게 되는 구조가 될것이기 때문이다.
다른 구분값이 있다면, 수정 유무를 내가 판단해주면 되겠지만
구분 키가 없다면, 나는 수정유무 + 중복유무 같은 로직을 수행하기가 어려워진다.

const {
data: scrollData,
isFetchingNextPage,
observerRef,
isLoading,
} = useInfiniteScrollQuery({
queryKey: ["func"],
queryFn: ({ pageParam = 1 }: { pageParam: number | boolean }) => {
return api.func({
page: String(pageParam),
...param,
});
},
getNextPageParam: (returnData) => {
return totalCount - perPage * page >= perPage
? page + 1
: undefined;
},
});