개발

Intersection Observer API #2 사용 예시 - 무한 스크롤 구현하기. 근데 이제 useInfiniteQuery를 곁들인

dohye1 2022. 11. 15. 10:12
반응형

Intersection Observer를 사용해서 무한 스크롤 예시를 만들어보자!

Using

1. CRA with typescript

2. react-query

3. randomstring

 

간단하게 만들어보겠다.

 

먼저 react-query 사용환경 설정해주기

최상단 컴포넌트에서 QueryClientProvider로 감싸줘야한다.

 

나는 App.tsx에서 적용해줌

import { QueryClient, QueryClientProvider } from "react-query";
import List from "./List";

const client = new QueryClient();

function App() {
  return (
    <QueryClientProvider client={client}>
      <List />
    </QueryClientProvider>
  );
}

export default App;

 

그리고 데이터를 생성해줄 함수를 간단하게 만들어봤다.

import { generate } from "randomstring";

export type ItemType = {
  id: number;
  text: string;
};

type APIData<T> = {
  data: T;
  totalPage: number;
  currentPage: number;
};

const TOTAL_PAGE = 5;

const makeData = (page: number, size: number): Promise<APIData<ItemType[]>> => {
  return new Promise((resolve, reject) => {
    if (page > TOTAL_PAGE) {
      reject();
    }
    setTimeout(() => {
      const newList = Array.from({ length: size }).map(
        (_, index) =>
          ({
            id: page * size + index,
            text: generate({ readable: true, length: 10 }),
          })
      );
      resolve({ data: newList, totalPage: TOTAL_PAGE, currentPage: page });
    }, 1000);
  });
};

export default makeData;

(간단하게 만들거라 타입이랑 상수도 한개의 파일에 선언해줌)

- 인자로 page와 size를 받아서 해당 설정값에 맞는 리스트를 반환하는 함수이다.

- text를 랜덤하게 생성해주는 randomstring이라는 라이브러리를 사용해봄

 

 

그리고 아래는 List.tsx 컴포넌트이다.

react-query에서 제공해주는 useInfiniteQuery와 IntersectionObserver를 조합해서 사용해봤다.

import { useEffect, useRef } from "react";
import { useInfiniteQuery } from "react-query";
import makeData from "./makeData";
import Item from "./Item";
import "./List.css";

const DATA_SIZE = 10;

function List() {
  const wrapperRef = useRef<HTMLDivElement>(null);
  const loadElement = useRef<HTMLDivElement>(null);

  const {
    data,
    fetchNextPage,
    isFetchingNextPage,
    hasNextPage = true,
  } = useInfiniteQuery(
    ["list"],
    ({ pageParam = 0 }) => makeData(pageParam, DATA_SIZE),
    {
      getNextPageParam: (lastPage) => {
        if (
          lastPage.totalPage === 0 ||
          lastPage.currentPage + 1 > lastPage.totalPage
        ) {
          return undefined;
        }
        return lastPage.currentPage + 1;
      },
    }
  );

  const onIntersect = (
    entries: IntersectionObserverEntry[],
    observer: IntersectionObserver
  ) => {
    const [entry] = entries;
    if (entry.isIntersecting && !isFetchingNextPage) {
      fetchNextPage();
    }
  };

  useEffect(() => {
    let observer: IntersectionObserver;
    if (wrapperRef.current && loadElement.current) {
      observer = new IntersectionObserver(onIntersect, {
        threshold: 0,
        root: wrapperRef.current,
      });
      observer.observe(loadElement.current);
    }
    return () => {
      observer?.disconnect();
    };
  }, []);

  return (
    <div className="list" ref={wrapperRef}>
      {data?.pages.map((value) =>
        value.data.map((item) => <Item {...item} key={item.id} />)
      )}
      {hasNextPage && (
        <div ref={loadElement} className="loading">
          Loading...
        </div>
      )}
    </div>
  );
}

export default List;

위의 코드는 List의 전체코드이다.

이제 한 부분씩 뜯어보자!

  return (
    <div className="list" ref={wrapperRef}>
      {data?.pages.map((value) =>
        value.data.map((item) => <Item {...item} key={item.id} />)
      )}
      {hasNextPage && (
        <div ref={loadElement} className="loading">
          Loading...
        </div>
      )}
    </div>
  );

엘리먼트를 먼저 보면 리스트를 감싸는 div에 wrapperRef로 ref를 설정해줬다.

그리고 이 div의 높이를 지정하고, overflow-y : auto로 해줘야한다!

(높이를 주지않으면 맨처음에만 데이터가 받아와지고, 로딩이 화면에 보여도 데이터가 더이상 받아와지지않는다. 왜냐하면 loding div가 wrapperRef와 교차할때에만 callback이 실행되는데, 화면에 계속 노출되어있으면 callback이 실행되지않기때문인것같음!! 아마...? 이건 다음글에서 자세히 분석해보겠다.)

.list {
  display: flex;
  flex-direction: column;
  row-gap: 10px;
  height: 70vh;
  margin: 0 auto;
  overflow-y: auto;
  background-color: rgb(129, 73, 129);
}


.loading {
  background-color: antiquewhite;
  padding: 20px;
  width: 100%;
  box-sizing: border-box;
  font-size: 20px;
  font-weight: 600;
  font-family: sans-serif;
  text-align: center;
}

리스트의 스타일을 이렇게 설정해줬다.

그리고 로딩도 높이를 가지도록 설정해줬다.

list안에 그려지는 아이템들도 적당한 높이를 가져야한다. 그냥 로딩이랑 스타일 똑같이 설정해주기!(왜냐하면 첫 데이터 페칭 후 로딩이 계속 뷰포트에 그려져있으면, 다음 페이지를 받아오지 않음. 위에서 말한 이유 때문인것같은데, 이 부분에 대한 해결책은 다음 글에서 적어보겠다)

 

 

  useEffect(() => {
    let observer: IntersectionObserver;
    if (wrapperRef.current && loadElement.current) {
      observer = new IntersectionObserver(onIntersect, {
        threshold: 0,
        root: wrapperRef.current,
      });
      observer.observe(loadElement.current);
    }
    return () => {
      observer?.disconnect();
    };
  }, []);

위 코드에서 IntersectionObserver에 감시할 대상을 등록한다.

threshold를 0으로 설정하여, loadElement가 wrapperRef와 viewport내에서 겹쳐지기 시작하는순간에 callback이 실행된다.

  const onIntersect = (
    entries: IntersectionObserverEntry[],
    observer: IntersectionObserver
  ) => {
    const [entry] = entries;
    if (entry.isIntersecting && !isFetchingNextPage) {
      fetchNextPage();
    }
  };

그리고 IntersectionObserver에 넘겨주는 callback을 보면

entry에 들어있는 isIntersecting 값을 사용해서 데이터를 페칭할지를 결정한다.

 

 

useInfiniteQuery를 간단하게 살펴보자면 

  const {
    data,
    fetchNextPage,
    isFetchingNextPage,
    hasNextPage = true,
  } = useInfiniteQuery(
    ["list"],
    ({ pageParam = 0 }) => makeData(pageParam, DATA_SIZE),
    {
      getNextPageParam: (lastPage) => {
        if (
          lastPage.totalPage === 0 ||
          lastPage.currentPage + 1 > lastPage.totalPage
        ) {
          return undefined;
        }
        return lastPage.currentPage + 1;
      },
    }
  );

우선 인자부터 확인해보면

첫번째 인자 : querykey

두번째 인자 : 데이터를 반환해주는 함수를 적어주면 될듯? 인자로 넘어오는 pageParam를 page index로 쓰면됩니당

세번째 인자 : 다음페이지를 페칭할지 안할지 결정하는 함수이다. 만약 저 함수가 undefined를 반환하면 더이상 다음페이지를 불러오지않고, undefined 이외의 값을 반환하면, 다음 페이지 요청을 보낸다.

 

그리고 결과값을 확인해보면 아주 다양한데, 나는 필요한것들만 뽑아썼다.

data : 아래의 사진을 참고하면 된다. data의 pages안에 내가 받아온 데이터가 pageParam 과 순서가 맞게 들어가있다.

그래서 data.pages에서 데이터를 뽑아서 쓰면된다.

fetchNextPage : 다음 순서의 데이터를 받아오고싶을때 호출하면된다.

isFetchingNextPage : fetchNextPage가 호출되어 다음 페이지를 받아올때 true가 됨

hasNextPage : 다음 페이지가 존재하는지에 대한 boolean 값

 

결과물