티스토리 뷰

🌴 React Query (현 TanStack Query)

서버의 데이터 가져오기/업데이트, 캐싱, 에러 처리 등을 쉽게 할 수 있도록 돕는 라이브러리

서버에서 가져온 값을 담는 객체, 에러가 발생했을 때 에러 정보를 담는 객체, 데이터 가져오기/업데이트 중임을 나타내는 등 각종 유틸 기능을 제공한다.

동일한 요청을 동시에 여러번 해도 한번만 요청을 보내 최적화하기 때문에 비동기 작업을 좀 더 효율적이고 간단하게 처리할 수 있게 한다.

 

 

 

cursor에 따라 postId 변경하기

http.get("/api/postRecommends", ({ request }) => {
    const url = new URL(request.url);
    // cursor의 기본값은 0
    const cursor = parseInt(url.searchParams.get("cursor") as string) || 0;
    return HttpResponse.json([
      {
        postId: cursor + 1, // cursor에 따라 id가 변경됨
        User: User[0],
        content: `${cursor + 1} Z.com is so marvelous. I'm gonna buy that.`,
        Images: [{ imageId: 1, link: faker.image.urlLoremFlickr() }],
        createdAt: generateDate(),
      },
      {
        postId: cursor + 2,
        User: User[0],
        content: `${cursor + 2} Z.com is so marvelous. I'm gonna buy that.`,
        Images: [
          { imageId: 1, link: faker.image.urlLoremFlickr() },
          { imageId: 2, link: faker.image.urlLoremFlickr() },
        ],
        createdAt: generateDate(),
      },
      {
        postId: cursor + 3,
        User: User[0],
        content: `${cursor + 3} Z.com is so marvelous. I'm gonna buy that.`,
        Images: [],
        createdAt: generateDate(),
      },
      {
        postId: cursor + 4,
        User: User[0],
        content: `${cursor + 4} Z.com is so marvelous. I'm gonna buy that.`,
        Images: [
          { imageId: 1, link: faker.image.urlLoremFlickr() },
          { imageId: 2, link: faker.image.urlLoremFlickr() },
          { imageId: 3, link: faker.image.urlLoremFlickr() },
          { imageId: 4, link: faker.image.urlLoremFlickr() },
        ],
        createdAt: generateDate(),
      },
      {
        postId: cursor + 5,
        User: User[0],
        content: `${cursor + 5} Z.com is so marvelous. I'm gonna buy that.`,
        Images: [
          { imageId: 1, link: faker.image.urlLoremFlickr() },
          { imageId: 2, link: faker.image.urlLoremFlickr() },
          { imageId: 3, link: faker.image.urlLoremFlickr() },
        ],
        createdAt: generateDate(),
      },
    ]);
  }),

데이터는 5개의 더미 데이터를 사용하고 있다.

request의 url에서 cursor를 가져와 데이터의 postId로 사용한다.

 

더보기

postId=0이면 postId가 1, 2, 3, 4, 5인 데이터 5개를 전달

postId=5이면 postId가 6, 7, 8, 9, 10인 데이터 5개를 전달

postid=10이면 postId가 11, 12, 13, 14, 15인 데이터 5개를 전달

이 동작을 반복해 데이터를 계속 불러온다.

 

 

 

데이터 요청하기

- 서버 컴포넌트

무한 스크롤을 적용할 페이지에서 기존에 사용하던 queryClient.prefetchQuery를 queryClient.prefetchInfiniteQuery로 변경한다.

  const queryClient = new QueryClient();
  await queryClient.prefetchInfiniteQuery({
    queryKey: ["posts", "recommends"],
    queryFn: getPostRecommends,
    initialPageParam: 0,
  });

initialPageParam은 cursor의 초기값으로 0을 전달한다.

 

 

- 클라이언트 컴포넌트

클라이언트 컴포넌트에서는 useQuery대신 useInfiniteQuery를 사용한다.

const { data } = useInfiniteQuery<
    IPost[],
    Object,
    InfiniteData<IPost[]>,
    [_1: string, _2: string],
    number // initialPageParam 타입
    >({
    queryKey: ["posts", "recommends"],
    queryFn: getPostRecommends,
    initialPageParam: 0,
    getNextPageParam: (lastPage) => lastPage.at(-1)?.postId,
    staleTime: 60 * 1000,
});

initialPageParam을 전달하고 getNextPageParam를 추가한다.

lastPage는 가장 최근에 불러온 5개의 데이터가 들어있어 lastPage의 가장 마지막 데이터의 postId를 cursor로 전달한다.

 

 

+ getPostRecommend 함수에서 cursor 보내기

getPostRecommend 함수에서 pageParam을 받아 그대로 전달한다.

type Props = { pageParam?: number };

export async function getPostRecommends({ pageParam }: Props) {
  const res = await fetch(
    `http://localhost:9090/api/postRecommends?cursor=${pageParam}`,
    {
      next: {
        tags: ["posts", "recommends"],
      },
    }
  );

  if (!res.ok) {
    throw new Error("Failed to fetch data");
  }

  return res.json();
}

 

 

데이터 표시하기

useInfiniteQUery는 데이터를 페이지별로 따로 관리한다.

[[1, 2, 3, 4, 5],[6, 7, 8, 9, 10], [11, 12, 13, 14, 15]]

데이터가 2차원 배열로 들어가있기 때문에 data.map으로 바로 접근할 수 없다.

따라서 data의 pages에 들어있는 배열을 하나씩 꺼낸 다음 다시 map으로 데이터를 꺼내 표시한다.

{data?.pages.map((page, i) => (
    <Fragment key={i}>
      {page.map((post) => (
        <Post key={post.postId} post={post} />
      ))}
    </Fragment>
))}

 

 

 

 

 

 

무한 스크롤링 구현하기

스크롤이 페이지의 가장 하단에 닿았을 때 getPostRecommend 함수를 실행시킨다.

이 동작은 Intersection Observer를 사용해 이벤트를 호출한다.

 

useInfiniteQuery에서 fetchNextPage와 hasNextPage를 꺼내온다.

  • fetchNextPage : 데이터 요청 함수
  • hasNextPage : 다음 페이지가 있는지를 나타내는 boolean값
    • 데이터가 다 불러와진 경우 다음 페이지가 없으므로 false
  const { data, fetchNextPage, hasNextPage, isFetching } = useInfiniteQuery<
    IPost[],
    Object,
    InfiniteData<IPost[]>,
    [_1: string, _2: string],
    number // initialPageParam 타입
  >({
    queryKey: ["posts", "recommends"],
    queryFn: getPostRecommends,
    initialPageParam: 0,
    getNextPageParam: (lastPage) => lastPage.at(-1)?.postId,
    staleTime: 60 * 1000,
  });

 

 

react-intersection-observer를 설치하고 화면 위치를 감지할 수 있도록 div를 추가한다.

<>
  {data?.pages.map((page, i) => (
    <Fragment key={i}>
      {page.map((post) => (
        <Post key={post.postId} post={post} />
      ))}
    </Fragment>
  ))}
  <div style={{ height: 50 }} />
</>

div를 인식하면(화면에 나타나면) fetchNextPage 함수를 호출한다.

 

useInView 훅을 사용해 특정 컴포넌트가 화면에 나타날 경우 함수를 실행하도록 한다.

import { useInView } from "react-intersection-observer";

...

const { ref, inView } = useInView({
    threshold: 0, // 화면에 몇 픽셀이 표시된 이후 이벤트가 발생하는지
    delay: 1500, // 화면에 나타난 이후 몇 초뒤에 이벤트가 발생하는지
});

...

return (
	<>
    	...
        <div ref={ref} style={{ height: 50 }} />
    </>
)

ref인 div 태그가 화면에 나타나면 inView는 true, 화면에서 사라지면 false가 된다.

 

 

useEffect로 inView가 변함에 따라 fetchNextPage 함수를 실행한다.

  useEffect(() => {
    if (inView) {
      // 화면에 보일 떄
      !isFetching && hasNextPage && fetchNextPage(); // 데이터를 가져오는 중이 아닐 떄 다음 페이지가 존재하면 데이터 요청
    }
  }, [inView, isFetching, hasNextPage, fetchNextPage]);

 idFetching을 추가해 데이터를 가져오고 있는 상황이 아닌 경우에 데이터요청을 하도록 한다.

이로써 같은 데이터를 여러번 가져오는 실수를 줄일 수 있다.

 

 

 

728x90