- 기존에는 product 갯수가 20개였지만, 60개로 늘려서 product.json 파일을 수정한다.
- 60개의 상품을 4개의 페이지로 나눈다고 생각하면 id 1~15번인 15개의 상품을 먼저 보여주고, 커서가 끝까지 갔을 때 id가 16~30번인 15개의 상품을 보여주고 ... 하는 식으로 무한 스크롤로 페이지를 나눌 수 있다.
무한스크롤 적용하기
커서 Pagination
- 서버에서 cursor를 argument로 받아주어야 한다.
- 커서를 아이디 값으로 하면 그 값에 따라 상품이 변경되어 보이게 할 수 있다.
▼ server/src/schema/products.ts
extend type Query {
products(cursor: ID): [Product!]
product(id: ID!): Product!
}
- 페이지 번호를 인덱스라고 생각했을 때 해당 인덱스부터 15개씩 상품을 보여주도록 한다.
▼ server/src/resolvers/products.ts
products: (parent, { cursor = "" }, { db }) => {
const fromIndex =
db.products.findIndex((product) => product.id === cursor) + 1;
return db.products.slice(fromIndex, fromIndex + 15) || [];
},
서버의 설정은 끝났다.
useInfiniteQuery
- useInfiniteQuery는 일반적인 useQuery와 매우 유사하지만, 페이지네이션, 무한 스크롤, 커서 기반 페이지네이션 등과 같은 사용 사례를 지원하는 데 특화되어 있다.
useInfiniteQuery 사용하기
▼ client/src/pages/products.tsx
const { data, isSuccess, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery<Products>(
QueryKeys.PRODUCTS,
({ pageParam = "" }) =>
graphqlFetcher<Products>(GET_PRODUCTS, { cursor: pageParam }),
{
getNextPageParam: (lastpage, allpages) => {
return lastpage.products.at(-1)?.id;
},
}
);
- at(-1)을 하면 가장 마지막 요소를 반환한다.
- lastpage와 allpages, data를 콘솔에 출력해보면 lastpage.products에 원하는 데이터들이 존재하고, allpages.0.products 안에 데이터들이 존재하고, data.pages.0.products에 원하는 데이터들이 존재하는 것을 확인할 수 있다.
ProductsList 불러오기 수정
상품목록 화면에서 ProductItem 컴포넌트를 호출하면서 data.pages를 props로 넘겨줄 것이므로, productList에서 props로 list를 전달받으면, list에서 이중 map을 돌려야 원하는 data에 접근할 수 있다.
▼ client/src/components/list.tsx
const ProductList = ({ list }: { list: { products: Product[] }[] }) => {
return (
<ul className="products">
{list.map((page) =>
page.products.map((product) => (
<ProductItem {...product} key={product.id} />
))
)}
</ul>
);
};
InterSectionObserver
사용자가 화면에 끝 지점에 도달했는지 확인하는 방법 두가지
- 전통적인 방법
: scrollTop = window.height 등을 이용해서 정말 도달했는지 계속 감지하는 방법
- eventHandler로 (scroll) 감시를 계속해주어야 하며, throttle, debounce처리까지 필요할 수 있다.
→ 스레드 메모리를 사용하게 되며, 성능에도 좋지 않다. - interSectionObserver 이용하는 방법
: 이벤트 등록 x, 브라우저에서 제공하는 별개의 감지자
- 싱글스레드인 자바스크립트와 별개로 동작하므로 성능 문제 발생 x
InterSecrionObserver
:Intersection Observer는 웹 API로, 뷰포트와 요소 간의 교차 영역을 감지하는 기능을 제공한다. 이를 통해 요소가 뷰포트에 들어오거나 나갈 때를 감지하고, 이벤트를 처리할 수 있다.
InterSectionObserver in React
- 보통 ref를 사용한다.
const observerRef = useRef<IntersectionObserver>();
- entries는 ntersectionObserverEntry 인스턴스의 배열인데, 어떤 값이 들어가는 지 확인하기 위해 console에 출력해볼 것이다.
▼ client/src/pages/products.tsx
const getObserver = useCallback(() => {
if (!observerRef.current) {
observerRef.current = new IntersectionObserver((entries) => {
console.log("entries", entries);
});
}
return observerRef.current;
}, [observerRef.current]);
- 관찰할 div요소를 지정하기 위해서 useRef인 fetchMoreRef를 새로 정의하고 상품목록 리스트 아래에 div를 새로 생성하여 ref로 지정한다.
const fetchMoreRef = useRef<HTMLDivElement>(null);
- 그리고 나서 useEffect로 fetchMoreRef.current가 바뀔 때마다 observe를 한다.
useEffect(() => {
if (fetchMoreRef.current) {
getObserver().observe(fetchMoreRef.current);
}
}, [fetchMoreRef.current]);
- 해당 div가 사용자의 화면에 안보일 때에는 entries의 isIntersecting 값이 false였다가, div가 화면에 보이게 되면 isIntersecting true로 바뀌는 것을 확인할 수 있다.
- intersecting이라는 state를 생성하고, isIntersecting의 값에 따라서 setIntersecting을 적용한다.
const [intersecting, setIntersecting] = useState(false);
setIntersecting(entries[0]?.isIntersecting);
- 위에서 정의한 intersecting에 따라 무한 스크롤을 적용하기 위해 useEffect의 deps에 intersecting에를 넣고 아래와 같이 정의한다.
- intersecting이 false이거나 (다음div가 보이지 않음),
isSuccess가 false이거나 (데이터가 로드되지 않음),
hasNextPage가 false이거나 (더 이상 로드할 다음 페이지가 없음), isFetchingNextPage가 true이면 (다음 페이지를 가져오는 요청이 아직 진행중)
다음페이지를 로드하지 않는다. - 그렇지 않은 경우에는 다음페이지를 로드한다. (fetchNextPage)
useEffect(() => {
if (!intersecting || !isSuccess || !hasNextPage || isFetchingNextPage)
return;
fetchNextPage();
}, [intersecting]);
스크롤 할 때마다 데이터가 추가되어 보여지고 있는 것을 확인할 수 있다.
observer를 쓰는 부분의 코드가 길고 재사용할 가능성이 있으므로 따로 hooks로 저장해둘 것이다.
▼ client/src/hooks/useIntersection.ts
const useInfiniteScroll = (targetRef: RefObject<HTMLElement>) => {
const observerRef = useRef<IntersectionObserver>();
const [intersecting, setIntersecting] = useState(true);
const getObserver = useCallback(() => {
if (!observerRef.current) {
observerRef.current = new IntersectionObserver((entries) => {
setIntersecting(entries.some((entry) => entry.isIntersecting));
});
}
return observerRef.current;
}, [observerRef.current]);
useEffect(() => {
if (targetRef.current) {
getObserver().observe(targetRef.current);
}
}, [targetRef.current]);
return intersecting;
};