React

React Query๋กœ infinite scroll(๋ฌดํ•œ ์Šคํฌ๋กค) ๊ตฌํ˜„ํ•˜๊ธฐ

presentKey 2023. 11. 23. 01:35

๐Ÿšฉ ํŽ˜์ด์ง€ ํ•˜๋‹จ์— ๋„๋‹ฌํ–ˆ๋Š”์ง€ ํ™•์ธํ•˜๋Š” ๋ฐฉ๋ฒ•

  • Intersection Observer API
    • ํƒ€๊ฒŸ ์š”์†Œ(element)์™€ ๋ธŒ๋ผ์šฐ์ € viewport์˜ ๊ต์ฐจ์ ์„ ๊ด€์ฐฐํ•ฉ๋‹ˆ๋‹ค.
      • ํƒ€๊ฒŸ ์š”์†Œ๊ฐ€ ์‚ฌ์šฉ์ž ํ™”๋ฉด์— ๋ณด์ด๋Š”์ง€ ์•ˆ๋ณด์ด๋Š”์ง€ ํŒ๋‹จ
      • ํ™”๋ฉด์— ๋ณด์ด๋ฉด ์ง€์ •ํ•œ ์ฝœ๋ฐฑ ํ•จ์ˆ˜ ์‹คํ–‰
    • scroll ์ด๋ฒคํŠธ์™€ ๋‹ฌ๋ฆฌ ๋น„๋™๊ธฐ์ ์œผ๋กœ ์‹คํ–‰๋˜๋ฉฐ, reflow๋ฅผ ๋ฐœ์ƒ์‹œํ‚ค์ง€ ์•Š์•„ ์„ฑ๋Šฅ ์ด์ ์ด ์žˆ์Šต๋‹ˆ๋‹ค.
    • https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API

 

๐Ÿšฉ 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์„ ํ†ตํ•ด ๋ฐ›์•„์˜จ ๋ฐ์ดํ„ฐ๋ฅผ ๊ฐ€๊ณตํ•  ์ˆ˜ ์žˆ๋Š” ์˜ต์…˜

select ๊ฐ€๊ณต ์ „

๋ฐ์ดํ„ฐ ๊ฐ€๊ณต ์ „์—๋Š” pages ๋ฐฐ์—ด → items ๋ฐฐ์—ด ๋‘ ๋ฒˆ ์ ‘๊ทผํ•ด์•ผ ๋ฆฌ๋ทฐ ๋ฐ์ดํ„ฐ๋ฅผ ์–ป์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

select ๊ฐ€๊ณต ํ›„

react-query select ์˜ต์…˜๊ณผ flatMap ๋ฉ”์„œ๋“œ๋ฅผ ์ ์šฉํ•˜๋ฉด, pages ๋ฐฐ์—ด์— ํ•œ ๋ฒˆ๋งŒ ์ ‘๊ทผํ•˜์—ฌ ๋ฆฌ๋ทฐ ๋ฐ์ดํ„ฐ๋ฅผ ์–ป์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๐Ÿšฉ ํ”„๋กœ์ ํŠธ ์ ์šฉ

 

๐Ÿšฉ ์ฐธ๊ณ