React with GraphQL Pagination
이 포스트에서는 프론트엔드 관련 코드만을 다룹니다.
Relay’s Cursor Based Connection Pattern으로 pagination을 구현합니다.
데이터 가져오기
GraphQL query 작성
- 서버에서 pagination 데이터를 가져오기 위한 query를 작성한다.
const graphql = gql`
query PaginationUserBookmarksOnFeed($first: Int, $after: PaginationCursor) {
paginationUserBookmarks(first: $first, after: $after) {
pageInfo {
hasNextPage
endCursor
}
edges {
cursor
node {
id
interest {
id
interest
}
tags {
id
tag
}
...
}
}
}
}
`;
GraphQL query 실행
- 위에서 작성한 쿼리를 functional component안에서 Apollo-Client가 만들어준
use[PaginationUserBookmarksOnFeed]Query()
hook으로 실행시킨다.
const {
data: feedData,
loading: isFeedDataLoading,
fetchMore,
} = usePaginationUserBookmarksOnFeedQuery({
variables: {
first: 4,
},
});
first
는 한번의 요청에 가져올 데이터 갯수를 의미한다.- 참고)
notifyOnNetworkStatusChange
속성을 true로 설정하고 networkStatus 값을 받으면 데이터를 가져오는 동안 로딩 처리를 할 수 있다. ex. spinner
fetchMoreFeedData 코드 작성
- pageInfo에 hasNextPage 값이 true라면 pageInfo.endCursor 다음 데이터 4개를 가져오도록 함수를 작성한다.
- hasNextPage 값이 false일 경우 다음 데이터가 없다는 것을 의미하므로 fetchMore() 함수를 실행시키지 않는다.
const fetchMoreFeedData = () => {
if (pageInfo?.hasNextPage) {
fetchMore({
variables: { first: 4, after: pageInfo.endCursor },
});
}
};
Apollo Client cache typePolicies 설정
-
typePolicies 설정에 아래 코드와 같이 위에서 작성한 query 이름에
relayStylePagination()
함수를 넣어준다.new ApolloClient({ link: authLink.concat(httpLink), cache: new InMemoryCache({ typePolicies: { Query: { fields: { paginationUserBookmarks: relayStylePagination(), }, }, }, }), });
-
typePolices 설정은 GraphQL 스키마에 대한 연속 호출 데이터가 기존 캐시와 병합되고 저장되는 방법을 정의하는 곳이다.
ex)
-
호출 1: 데이터 0번에서 9번까지 가져오기
-
호출 2: 데이터 10번에서 19번까지 가져오기
-
호출 1번을 통해 가져온 데이터와 호출 2번을 통해 가져온 데이터를 어떻게 병합해서 캐시에 저장할 것인지를 정의한다.
-
-
relayStylePagination()
함수는 Cursor-based pagination에 맞게 간편하게 사용할 수 있도록 미리 정의된 라이브러리이다.
Intersection Observer API 적용하기
The Intersection Observer API provides a way to asynchronously observe changes in the intersection of a target element with an ancestor element or with a top-level document’s viewport.
Intersection Observer 생성하기
const options: IntersectionObserverInit = {
threshold: 0.5,
};
const intersectionObserver = new IntersectionObserver(handleIntersection, options);
- options에 root 설정을 하지 않으면 브라우저 뷰포트가 기본값이 된다.
- threshold 0.5 설정을 하면 뷰포트에 target이 50% 교차되면 callback(handleIntersection) 함수가 동작하게 된다.
Target 설정하기
cursor === pageInfo?.endCursor
인 경우(서버에서 가져온 마지막 데이터인 경우), ref 값을 target으로 설정해준다.
export const HomeFeed = () => {
const { entries, pageInfo, fetchMoreFeedData } = useDataAccessFeed();
const target = useRef<HTMLDivElement>(null);
return (
<div className="...">
{entries?.map(({ id, cursor, ...}, index) => {
return (
<div key={id} ref={cursor === pageInfo?.endCursor ? target : null} className="...">
<ShadowCard ... />
</div>
);
})}
</div>
);
};
Callback(handleIntersection
) 작성하기
IntersectionObserverEntry.isIntersecting
: true이면 뷰포트에 교차되는 변화가 일어나고 있다는 것을 의미하고, false이면 뷰포트에 교차되지 않는 변화가 일어나고 있다는 것을 의미한다.- 따라서
entry.isIntersecting
true일 경우fetchMore[FeedData]()
함수를 실행시키고, 새로운 target을 observer에 등록한다.
const handleIntersection = (
[entry]: IntersectionObserverEntry[],
observer: IntersectionObserver,
) => {
if (!entry.isIntersecting) {
return;
}
fetchMoreFeedData();
observer.unobserve(entry.target);
if (target.current) {
observer.observe(target.current);
}
};
최종 코드
export const HomeFeed = () => {
const { entries, pageInfo, fetchMoreFeedData } = useDataAccessFeed();
const target = useRef<HTMLDivElement>(null);
useEffect(() => {
const options: IntersectionObserverInit = {
threshold: 0.5,
};
const handleIntersection = ([entry]: IntersectionObserverEntry[], observer: IntersectionObserver) => {
if (!entry.isIntersecting) {
return;
}
fetchMoreFeedData();
observer.unobserve(entry.target);
if (target.current) {
observer.observe(target.current);
}
};
const intersectionObserver = new IntersectionObserver(handleIntersection, options);
if (target.current) {
intersectionObserver.observe(target.current);
}
return () => intersectionObserver && intersectionObserver.disconnect();
}, [fetchMoreFeedData]);
return (
<div className="...">
{entries?.map(({ id, cursor, ... }, index) => {
return (
<div key={id} ref={cursor === pageInfo?.endCursor ? target : null} className="...">
<ShadowCard ... />
</div>
);
})}
</div>
);
};
주의할 점
- fetchMore 함수를 실행하면 다음 데이터가 불러와지는데, 이때 기존 cache 데이터에 제대로 병합이 되지 않는다면 이 링크를 읽어보는 것이 좋다.
- Apollo는 쿼리 인수에 after가 포함되어 있으면 before 이전 페이지를 병합한다. 따라서 first, after, before, last가 어떤 객체의 속성이 아닌 인수로 선언되어야 한다.
ex)
paginationUserBookmarks(first: $first, after: $after)