react

react-query를 이용해 pagenation을 구현해보자

냠냠맨 2023. 6. 29. 19:41

pagenation이란?

페이지네이션은 일정한 갯수로 아이템을 보여주는 것을 의미할 수 있습니다.

페이지네이션을 위한 간단한 알고리즘이 존재하니 기억해두시면 좋습니다.

 

그렇게 어렵지는 않은데 버튼을 일정하게 유지시켜주기 위해서 필요한 알고리즘입니다.

예컨대 1부터 10까지의 페이지를 보여주는 위 그림과 같은 페이지버튼이 있다고 가정해보면

1 2 3 4 5 6 7 8 9 10 이 나열되어야 하고

11페이지로 넘어가게되면

11 12 13 14 15 16 17 18 19 20이 나열되어야 할 것입니다.

이를 위해서 사용되어야하는 알고리즘입니다.

  const [listSize, setListSize] = React.useState(20);
  const [activePage, setActivePage] = React.useState(1);
  const [buttonCount, setButtonCount] = React.useState(10);
  let firstNum = activePage - (activePage % listSize) + 1;
  let lastNum = activePage - (activePage % listSize) + buttonCount;

React로 구현한다고하면 이와 같은 형태로 구현할 수 있을 것입니다.

listSize는 한번에 표시할 컨텐츠의 갯수를 의미합니다.

activePage는 현재 보고있는 페이지를 의미합니다.

buttonCount는 한번에 보여줄 버튼의 갯수를 의미합니다.

firstNum과 listNum은 버튼의 시작점과 끝점을 의미합니다.

 

하지만 이번에는 먼저 간단히 prev/next 버튼만 구현을 하고 넘어가보도록하겠습니다.


필요한 기능 정의하기

모두가 숫자 버튼이 있는 페이지네이션을 구현할 필요는 없습니다.

우선 기초적인 기능을 수행하는 페이지네이션을 만들어 볼 것입니다.

1. 20개 단위로 아이템을 불러올 수 있어야한다.

2. prev 버튼, next버튼을 누르면 페이지의 앞 / 뒤로 이동할 수 있어야 한다.

3. prefetch를 통해 다음에 볼 데이터를 미리 받아올 수 있어야한다.

 

이 세가지 조건을 만족하는 페이지네이션 예제를 만들어봅시다.

아주 운이 좋게도 리액트쿼리는 저 모든 요구사항을 만족시킬만큼 기능을 제공해줍니다.

https://tanstack.com/query/v4/docs/react/guides/paginated-queries#better-paginated-queries-with-keeppreviousdata

 

Paginated / Lagged Queries | TanStack Query Docs

Rendering paginated data is a very common UI pattern and in TanStack Query, it "just works" by including the page information in the query key:

tanstack.com

공식문서의 예제를 참고하며 작성했습니다.

최대한 적은 파일로 예제를 확인할 수 있도록 하기 위해

모듈화를 지양하면서 작성했습니다.

사용하는 api는 pokemon api입니다.


환경 설정

vite / typescript로 구축한 프로젝트입니다.

npx create-vite@latest

https://github.com/XionWCFM/react-query-infinite

 

GitHub - XionWCFM/react-query-infinite

Contribute to XionWCFM/react-query-infinite development by creating an account on GitHub.

github.com

자세한 코드는 위 레포에서 확인할 수 있습니다.

 

npm i @tanstack/react-query @tanstack/react-query-devtools

아래 실습에서 사용할 react-query 버전은 v4.29.14입니다.

App.tsx

function App() {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: {
        suspense: true,
      },
    },
  });

  return (
    <QueryClientProvider client={queryClient}>
      <main>
        <React.Suspense fallback={<Loading />}>
          <PagenationComponent />
        </React.Suspense>
      </main>
      <ReactQueryDevtools initialIsOpen={true} />
    </QueryClientProvider>
  );
}

export default App;

리액트 쿼리 사용을 위해서는 QueryClient와 QueryClientProvider가 필요합니다.

react가 제공하는 suspense 기능을 사용하고 싶으니

QueryClient의 인자를 전달하여 suspense:true를 주었습니다.

Suspense의 fallback으로 전달한 loading 컴포넌트는

쿼리를 받아오는 로딩 상태 동안 보여질 컴포넌트를 의미합니다.

PagenationComponent는 아직 만들지 않았으니 넘어갑시다.

import { useQuery, useQueryClient } from '@tanstack/react-query';
import React from 'react';

interface PokeType {
  name: string;
  url: string;
}

interface APIType {
  count: number;
  next: string;
  previous: string;
  results: PokeType[];
}

interface PagenationComponentProps {}

const getPagenation = async (page: number = 0) => {
  const response = await fetch(
    `https://pokeapi.co/api/v2/pokemon?limit=20&offset=${page}`,
  );
  return await response.json();
};

const PagenationComponent = ({}: PagenationComponentProps) => {
  const queryClient = useQueryClient();
  const [page, setPage] = React.useState(20);

  const pagenationQuery = useQuery<APIType>({
    queryKey: ['pagenation', page],
    queryFn: () => getPagenation(page),
    keepPreviousData: true,
    staleTime: 5000,
  });

  React.useEffect(() => {
    if (!pagenationQuery.isPreviousData) {
      queryClient.prefetchQuery({
        queryKey: ['pagenation', page + 20],
        queryFn: () => getPagenation(page + 20),
      });
    }
  }, [pagenationQuery.data, pagenationQuery.isPreviousData, page, queryClient]);

  console.log(pagenationQuery.data);

  return (
    <div>
      {pagenationQuery.data?.results.map((poke) => (
        <div key={poke.name}>{poke.name}</div>
      ))}
      <button
        onClick={() => {
          setPage((state) => Math.max(state - 20, 0));
        }}
      >
        prev button
      </button>
      <button
        onClick={() => {
          setPage((state) => state + 20);
        }}
      >
        next button
      </button>
    </div>
  );
};

export default PagenationComponent;

한파일에 모든것을 때려박은 형태로 작성해두었습니다.

하나하나 차근차근 분리해서 살펴보도록하겠습니다.

그 이전에 실제 동작이 어떻게 이루어지는지 확인하겠습니다.

페이지네이션 시연

네트워크창을 주목해서 보면 다음에 받아올 페이지를 미리 페칭해오는 것을 확인할  수 있습니다.

이제 하나하나 코드들을 살펴보도록 하겠습니다.

읽기만해도 코드가 이해되는 분들은 넘어가셔도 좋습니다.

const getPagenation = async (page: number = 0) => {
  const response = await fetch(
    `https://pokeapi.co/api/v2/pokemon?limit=20&offset=${page}`,
  );
  return await response.json();
};

useQuery의 queryFn에 들어갈 함수를 정의하는 부분입니다.

pokeapi는 limit과 offset을 설정해줄 수 있습니다.

20개씩 데이터를 받아올것이고 offset은 20씩 증가시키겠습니다.

만약 5, 10, 20 이런 형태로 선택할 수 있게 하고싶다면

limit에 들어갈 값도 동적으로 관리해주면 될것입니다.

 

다음은 받아오는 API의 타입을 정의해주겠습니다.

pokeapi의 data는 다음과 같은 형태로 보입니다.

count , next, previous, results 키가 존재하고

results 키는 name,url을 가진 객체들이 배열의 형태로 존재합니다.

interface PokeType {
  name: string;
  url: string;
}

interface APIType {
  count: number;
  next: string;
  previous: string;
  results: PokeType[];
}

따라서 간단하게 정의를 해주었습니다.

 

이제 리액트 컴포넌트 내부에서 useQuery로직을 작성하겠습니다.

  const queryClient = useQueryClient();
  const [page, setPage] = React.useState(20);

  const pagenationQuery = useQuery<APIType>({
    queryKey: ['pagenation', page],
    queryFn: () => getPagenation(page),
    keepPreviousData: true,
    staleTime: 5000,
  });

prefetch를 하기 위해 useQueryClient()를 사용하겠습니다.

또한 page값을 동적으로 관리해줄 것이니 useState로 상태를 만들어 관리해주겠습니다.

 

pagenationQuery는 useQuery의 반환값을 담을 것입니다.

리액트쿼리의 쿼리키는 마치 useEffect의 의존성 배열과도 비슷하게 생각할 수 있습니다.

keepPreviousData:true는 이전 데이터를 유지할 것인지를 묻는 옵션입니다.

staleTime은 데이터가 언제 stale해진 것으로 판단할지를 정하는 옵션입니다.

  React.useEffect(() => {
    if (!pagenationQuery.isPreviousData) {
      queryClient.prefetchQuery({
        queryKey: ['pagenation', page + 20],
        queryFn: () => getPagenation(page + 20),
      });
    }
  }, [pagenationQuery.data, pagenationQuery.isPreviousData, page, queryClient]);

이제 useEffect를 작성하고

if문에 해당되는 경우가 생기면 prefetchQuery를 날리는 로직을 작성하겠습니다.

isPreviousData는 boolean값이 담겨있는 프로퍼티입니다.

isPreviousData가 false일때 prefetching을 시도해주겠습니다.

이 useEffect가 있었기 때문에 앞서 본 시연화면에서

페이지가 넘어갈때마다 다음 페이지를 미리 prefetch할 수 있었던 것입니다.

      <button
        onClick={() => {
          setPage((state) => Math.max(state - 20, 0));
        }}

Math.max() 내장 메서드를 이용하여 state가 0미만으로 떨어지지 않게 관리해주었습니다.


마치며

이번 예제로 인해 어떤 느낌으로 페이지네이션을 구현할 수 있는지 알아보았으니

다음에는 좀 더 심화된 페이지네이션을 구현해보겠습니다.

반응형