๐ฉ ํ์ด์ง ํ๋จ์ ๋๋ฌํ๋์ง ํ์ธํ๋ ๋ฐฉ๋ฒ
- Intersection Observer API
- ํ๊ฒ ์์(element)์ ๋ธ๋ผ์ฐ์ viewport์ ๊ต์ฐจ์ ์ ๊ด์ฐฐํฉ๋๋ค.
- ํ๊ฒ ์์๊ฐ ์ฌ์ฉ์ ํ๋ฉด์ ๋ณด์ด๋์ง ์๋ณด์ด๋์ง ํ๋จ
- ํ๋ฉด์ ๋ณด์ด๋ฉด ์ง์ ํ ์ฝ๋ฐฑ ํจ์ ์คํ
- scroll ์ด๋ฒคํธ์ ๋ฌ๋ฆฌ ๋น๋๊ธฐ์ ์ผ๋ก ์คํ๋๋ฉฐ, reflow๋ฅผ ๋ฐ์์ํค์ง ์์ ์ฑ๋ฅ ์ด์ ์ด ์์ต๋๋ค.
- https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API
- ํ๊ฒ ์์(element)์ ๋ธ๋ผ์ฐ์ viewport์ ๊ต์ฐจ์ ์ ๊ด์ฐฐํฉ๋๋ค.
๐ฉ useIntersectionObserver hook
- useIntersectionObserver() ๊ตฌํ
export default function useIntersectionObserver({
target, // target ์์(element)
onIntersect, // ๊ต์ฐจ ์์ ์ ์คํ๋ ์ฝ๋ฐฑ ํจ์
threshold = 1.0, // ์ฝ๋ฐฑ ํจ์๊ฐ ์คํ๋๋ target์ ๊ฐ์์ฑ
enabled = true, // ๊ด์ฐฐ์ฌ๋ถ
}: UseIntersectionObserver) {
useEffect(() => {
// ๊ด์ฐฐํ์ง ์์
if (!enabled) {
return;
}
const observer = new IntersectionObserver(
// (boolean) entry.isIntersecting === true (๊ต์ฐจ ์ํ, target์ด viewport์ ๋ค์ด์ด)
// ์ง์ ํ ์ฝ๋ฐฑ ํจ์ ์คํ
(entries) =>
entries.forEach((entry) => entry.isIntersecting && onIntersect()),
{
threshold,
},
);
const element = target && target.current;
// IntersectionObserver Target์ด ์์ ๊ฒฝ์ฐ, ์ข
๋ฃ
if (!element) {
return;
}
observer.observe(element); // IntersectionObserver ์คํ
return () => observer.unobserve(element);
}, [enabled, threshold, target, onIntersect]);
}
- useIntersectionObserver() ์ ์ฉ
export default function TabMenu({ isMine, memberId }: TabMenuProps) {
const targetRef = useRef(null); // target element
const {
data: reviews,
isLoading: isLoadingReview,
fetchNextPage: fetchNextPageReview,
hasNextPage: hasNextPageReview,
} = useInfiniteQuery(
["profile", memberId, "review", selectedSort.id, selectedSort.isDESC],
({ pageParam }) => profile.getReview(memberId, pageParam, selectedSort),
{
getNextPageParam: (lastPage) => lastPage.cursor || undefined,
select: (data) => ({
pages: data.pages.flatMap((page) => page.items),
pageParams: data.pageParams,
}),
},
);
useIntersectionObserver({
target: targetRef,
onIntersect: fetchNextPageReview ,
enabled: hasNextPageReview,
});
return (
<>
...
<ReviewList isMine={isMine} list={reviews?.pages ?? []} />
<Target ref={targetRef} /> // ํ์ด์ง ํ๋จ target element
</>
)
}
๐ฉ useInfiniteQuery()
- react-query๋ ๋ฌดํ ์คํฌ๋กค ๊ตฌํ์ ๋์์ฃผ๋ useInfiniteQuery ํ ์ ์ ๊ณตํฉ๋๋ค.
const {
data: reviews,
isLoading: isLoadingReview,
fetchNextPage: fetchNextPageReview,
hasNextPage: hasNextPageReview,
} = useInfiniteQuery(
["profile", memberId, "review", selectedSort.id, selectedSort.isDESC],
({ pageParam }) => profile.getReview(memberId, pageParam, selectedSort),
{
getNextPageParam: (lastPage) => lastPage.cursor || undefined,
select: (data) => ({
pages: data.pages.flatMap((page) => page.items),
pageParams: data.pageParams,
}),
},
);
- returns
- fetchNextPage: ๋ค์ ํ์ด์ง์ ๋ฐ์ดํฐ๋ฅผ ๊ฐ์ ธ์ต๋๋ค. (useIntersectionObserver์ onIntersect ์ฝ๋ฐฑ ํจ์)
- hasNextPage: ๋ค์ ํ์ด์ง์ ์ฌ๋ถ (useIntersectionObserver์ enabled)
- getNextPageParam์ด undefined์ด ์๋ ๋ค๋ฅธ ๊ฐ์ ๋ฐํํ๋ฉด hasNextPage๋ true
- options
- getNextPageParam: ์ด ํจ์์ ๋ฐํ๊ฐ์ด queryFn์ pageParam์ผ๋ก ์ ๋ฌ๋ฉ๋๋ค. ๋ค์ ํ์ด์ง๊ฐ ์์์ ๋ํ๋ด๋ ค๋ฉด undefined๋ฅผ ๋ฐํํ๋ฉด ๋ฉ๋๋ค.
Oduck ํ๋ก์ ํธ์์ infinite scroll์ json ์๋ต ๋ฐ์ดํฐ๋ ๋ค์๊ณผ ๊ฐ์ต๋๋ค.
// ์ต์ ์ ์ ๋ ฌ
{
"items": [
{
"reviewId": 30,
...
},
{
"reviewId": 21,
...
}
],
"size": 10,
"hasNext": true,
"cursor": "2023-10-03T21:05:31.859"
}
1. getNextPageParam ์ต์ ์ ์ด์ฉํ์ฌ ๋ง์ง๋ง์ผ๋ก ๋ฐ์์จ ํ์ด์ง์ cursor ๋ฐํ
2. cursor๊ฐ์ด queryFn์ pageParam์ผ๋ก ์ ๋ฌ๋์ด ์๋ฒ์ ๋ค์ ํ์ด์ง ์์ฒญ
3. ๋ ์ด์ ๋ถ๋ฌ์ฌ ํ์ด์ง๊ฐ ์์ผ๋ฉด getNextPageParam์ undefined ๋ฐํ
4. hasNextPage๋ false
- select option
- queryFn์ ํตํด ๋ฐ์์จ ๋ฐ์ดํฐ๋ฅผ ๊ฐ๊ณตํ ์ ์๋ ์ต์

๋ฐ์ดํฐ ๊ฐ๊ณต ์ ์๋ pages ๋ฐฐ์ด โ items ๋ฐฐ์ด ๋ ๋ฒ ์ ๊ทผํด์ผ ๋ฆฌ๋ทฐ ๋ฐ์ดํฐ๋ฅผ ์ป์ ์ ์์ต๋๋ค.

react-query select ์ต์ ๊ณผ flatMap ๋ฉ์๋๋ฅผ ์ ์ฉํ๋ฉด, pages ๋ฐฐ์ด์ ํ ๋ฒ๋ง ์ ๊ทผํ์ฌ ๋ฆฌ๋ทฐ ๋ฐ์ดํฐ๋ฅผ ์ป์ ์ ์์ต๋๋ค.
- flatMap: https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Array/flatMap
๐ฉ ํ๋ก์ ํธ ์ ์ฉ
๐ฉ ์ฐธ๊ณ
- https://tanstack.com/query/v4/docs/react/reference/useInfiniteQuery
- https://velog.io/@cnsrn1874/react-query-useInfiniteQuery
- https://s0ojin.tistory.com/58
- https://velog.io/@leemember/React-query-%EC%82%AC%EC%9A%A9%ED%95%98%EB%8A%94-%EC%9D%B4%EC%9C%A0-useInfiniteQuery-%EB%A1%9C-%EB%AC%B4%ED%95%9C-%EC%8A%A4%ED%81%AC%EB%A1%A4-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-react-infinite-scroller
๐ฉ ํ์ด์ง ํ๋จ์ ๋๋ฌํ๋์ง ํ์ธํ๋ ๋ฐฉ๋ฒ
- Intersection Observer API
- ํ๊ฒ ์์(element)์ ๋ธ๋ผ์ฐ์ viewport์ ๊ต์ฐจ์ ์ ๊ด์ฐฐํฉ๋๋ค.
- ํ๊ฒ ์์๊ฐ ์ฌ์ฉ์ ํ๋ฉด์ ๋ณด์ด๋์ง ์๋ณด์ด๋์ง ํ๋จ
- ํ๋ฉด์ ๋ณด์ด๋ฉด ์ง์ ํ ์ฝ๋ฐฑ ํจ์ ์คํ
- scroll ์ด๋ฒคํธ์ ๋ฌ๋ฆฌ ๋น๋๊ธฐ์ ์ผ๋ก ์คํ๋๋ฉฐ, reflow๋ฅผ ๋ฐ์์ํค์ง ์์ ์ฑ๋ฅ ์ด์ ์ด ์์ต๋๋ค.
- https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API
- ํ๊ฒ ์์(element)์ ๋ธ๋ผ์ฐ์ viewport์ ๊ต์ฐจ์ ์ ๊ด์ฐฐํฉ๋๋ค.
๐ฉ useIntersectionObserver hook
- useIntersectionObserver() ๊ตฌํ
export default function useIntersectionObserver({
target, // target ์์(element)
onIntersect, // ๊ต์ฐจ ์์ ์ ์คํ๋ ์ฝ๋ฐฑ ํจ์
threshold = 1.0, // ์ฝ๋ฐฑ ํจ์๊ฐ ์คํ๋๋ target์ ๊ฐ์์ฑ
enabled = true, // ๊ด์ฐฐ์ฌ๋ถ
}: UseIntersectionObserver) {
useEffect(() => {
// ๊ด์ฐฐํ์ง ์์
if (!enabled) {
return;
}
const observer = new IntersectionObserver(
// (boolean) entry.isIntersecting === true (๊ต์ฐจ ์ํ, target์ด viewport์ ๋ค์ด์ด)
// ์ง์ ํ ์ฝ๋ฐฑ ํจ์ ์คํ
(entries) =>
entries.forEach((entry) => entry.isIntersecting && onIntersect()),
{
threshold,
},
);
const element = target && target.current;
// IntersectionObserver Target์ด ์์ ๊ฒฝ์ฐ, ์ข
๋ฃ
if (!element) {
return;
}
observer.observe(element); // IntersectionObserver ์คํ
return () => observer.unobserve(element);
}, [enabled, threshold, target, onIntersect]);
}
- useIntersectionObserver() ์ ์ฉ
export default function TabMenu({ isMine, memberId }: TabMenuProps) {
const targetRef = useRef(null); // target element
const {
data: reviews,
isLoading: isLoadingReview,
fetchNextPage: fetchNextPageReview,
hasNextPage: hasNextPageReview,
} = useInfiniteQuery(
["profile", memberId, "review", selectedSort.id, selectedSort.isDESC],
({ pageParam }) => profile.getReview(memberId, pageParam, selectedSort),
{
getNextPageParam: (lastPage) => lastPage.cursor || undefined,
select: (data) => ({
pages: data.pages.flatMap((page) => page.items),
pageParams: data.pageParams,
}),
},
);
useIntersectionObserver({
target: targetRef,
onIntersect: fetchNextPageReview ,
enabled: hasNextPageReview,
});
return (
<>
...
<ReviewList isMine={isMine} list={reviews?.pages ?? []} />
<Target ref={targetRef} /> // ํ์ด์ง ํ๋จ target element
</>
)
}
๐ฉ useInfiniteQuery()
- react-query๋ ๋ฌดํ ์คํฌ๋กค ๊ตฌํ์ ๋์์ฃผ๋ useInfiniteQuery ํ ์ ์ ๊ณตํฉ๋๋ค.
const {
data: reviews,
isLoading: isLoadingReview,
fetchNextPage: fetchNextPageReview,
hasNextPage: hasNextPageReview,
} = useInfiniteQuery(
["profile", memberId, "review", selectedSort.id, selectedSort.isDESC],
({ pageParam }) => profile.getReview(memberId, pageParam, selectedSort),
{
getNextPageParam: (lastPage) => lastPage.cursor || undefined,
select: (data) => ({
pages: data.pages.flatMap((page) => page.items),
pageParams: data.pageParams,
}),
},
);
- returns
- fetchNextPage: ๋ค์ ํ์ด์ง์ ๋ฐ์ดํฐ๋ฅผ ๊ฐ์ ธ์ต๋๋ค. (useIntersectionObserver์ onIntersect ์ฝ๋ฐฑ ํจ์)
- hasNextPage: ๋ค์ ํ์ด์ง์ ์ฌ๋ถ (useIntersectionObserver์ enabled)
- getNextPageParam์ด undefined์ด ์๋ ๋ค๋ฅธ ๊ฐ์ ๋ฐํํ๋ฉด hasNextPage๋ true
- options
- getNextPageParam: ์ด ํจ์์ ๋ฐํ๊ฐ์ด queryFn์ pageParam์ผ๋ก ์ ๋ฌ๋ฉ๋๋ค. ๋ค์ ํ์ด์ง๊ฐ ์์์ ๋ํ๋ด๋ ค๋ฉด undefined๋ฅผ ๋ฐํํ๋ฉด ๋ฉ๋๋ค.
Oduck ํ๋ก์ ํธ์์ infinite scroll์ json ์๋ต ๋ฐ์ดํฐ๋ ๋ค์๊ณผ ๊ฐ์ต๋๋ค.
// ์ต์ ์ ์ ๋ ฌ
{
"items": [
{
"reviewId": 30,
...
},
{
"reviewId": 21,
...
}
],
"size": 10,
"hasNext": true,
"cursor": "2023-10-03T21:05:31.859"
}
1. getNextPageParam ์ต์ ์ ์ด์ฉํ์ฌ ๋ง์ง๋ง์ผ๋ก ๋ฐ์์จ ํ์ด์ง์ cursor ๋ฐํ
2. cursor๊ฐ์ด queryFn์ pageParam์ผ๋ก ์ ๋ฌ๋์ด ์๋ฒ์ ๋ค์ ํ์ด์ง ์์ฒญ
3. ๋ ์ด์ ๋ถ๋ฌ์ฌ ํ์ด์ง๊ฐ ์์ผ๋ฉด getNextPageParam์ undefined ๋ฐํ
4. hasNextPage๋ false
- select option
- queryFn์ ํตํด ๋ฐ์์จ ๋ฐ์ดํฐ๋ฅผ ๊ฐ๊ณตํ ์ ์๋ ์ต์

๋ฐ์ดํฐ ๊ฐ๊ณต ์ ์๋ pages ๋ฐฐ์ด โ items ๋ฐฐ์ด ๋ ๋ฒ ์ ๊ทผํด์ผ ๋ฆฌ๋ทฐ ๋ฐ์ดํฐ๋ฅผ ์ป์ ์ ์์ต๋๋ค.

react-query select ์ต์ ๊ณผ flatMap ๋ฉ์๋๋ฅผ ์ ์ฉํ๋ฉด, pages ๋ฐฐ์ด์ ํ ๋ฒ๋ง ์ ๊ทผํ์ฌ ๋ฆฌ๋ทฐ ๋ฐ์ดํฐ๋ฅผ ์ป์ ์ ์์ต๋๋ค.
- flatMap: https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Array/flatMap
๐ฉ ํ๋ก์ ํธ ์ ์ฉ
๐ฉ ์ฐธ๊ณ
- https://tanstack.com/query/v4/docs/react/reference/useInfiniteQuery
- https://velog.io/@cnsrn1874/react-query-useInfiniteQuery
- https://s0ojin.tistory.com/58
- https://velog.io/@leemember/React-query-%EC%82%AC%EC%9A%A9%ED%95%98%EB%8A%94-%EC%9D%B4%EC%9C%A0-useInfiniteQuery-%EB%A1%9C-%EB%AC%B4%ED%95%9C-%EC%8A%A4%ED%81%AC%EB%A1%A4-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-react-infinite-scroller