react-query를 이용한 infinity-scroll 구현하기

김현진·2022년 5월 27일
11

react-query를 이용하면 infinite-scroll을 보다 쉽게 구현을 할 수 있습니다.

react-intersection-observer를 이용하여 infinite-scroll를 구현하는데 내부적으로 IntersectionObserver API를 사용을 합니다. 잘 모른다면 Intersection Observer API 알아보기

사용하는 Package

  • typescript
  • react
  • axios
  • react-query
  • react-intersection-observer

구현하기

1. 기본 셋팅.

import React from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';

import { ReactQueryDevtools } from 'react-query/devtools';

const queryClient = new QueryClient();


function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <ReactQueryDevtools initialIsOpen={false} /> // react-query dev-tools setting
      <Compoment />
    </QueryClientProvider>
  );
}

QueryClientProvider로 최상위 컴포넌트로 감싸고 개발시 필요한 react-query dev-tools setting을 셋팅합니다.(흔히 사용하는 provider pattern, react-query 내부적으로 캐싱을 하기위해서 Context-API를 사용한다고 합니다.)

2. Mock Data function

  • 우선 테스트를 하기위해선 데이터가 필요한데 Mock Data는 github API를 사용할 것입니다.
  • 데이터를 30개씩 가져오고 page parmeter로 어디서 부터 가져올지 설정
  import axios from axios;

  const fetchRepositories = async (page: number) => {
    return await axios
      .get<IRepository>(`https://api.github.com/search/repositories?q=topic:reactjs&per_page=30&page=${page}`)
      .then((resp) => resp.data);
  };

  • api response 값을 보면 데이터 구조가 복잡한데 typescript에서는 type을 선언을 해줘야 해서 복잡한 타입을 선언할때는 시간소요가 많이 듬. Type Parsing 사이트를 이용하면 시간 절약에 도움이 됩니다.

3. useInfiniteQuery를 구현하기

  • 우선 useInfiniteQuery를 사용하기 위해서는 react-query에서 제공되는 useQuery hook을 알면 이해하기 쉽습니다. 여기서는 다루지 않고 나중에 useQuery, useMutaion 등을 다루도록 하겠습니다.
const { data, status, hasNextPage, fetchNextPage, isFetchingNextPage, isFetching } = useInfiniteQuery<
    IRepository,
    Error,
    IRepository,
    [string] | string
  >( ['projects'],
    async ({ pageParam = 1 }) => {
      return await fetchRepositories(pageParam);
    },
    {
      getNextPageParam: (lastPage, allPages) => {
      
     // lastPage에는 fetch callback의 리턴값이 전달됨
     // allPage에는 배열안에 지금까지 불러온 데이터를 계속 축적하는 형태 [[data], [data1], .......]
        const maxPage = lastPage.total_count / 30; // 한번에 30개씩 보여주기
        const nextPage = allPages.length + 1; // 
        return nextPage <= maxPage ? nextPage : undefined; // 다음 데이터가 있는지 없는지 판단
      },
    }
  );
  • useInfiniteQuery 첫번째 인자값으로 query key를 쓰는데 string, array을 사용해도 됩니다. string을 사용하더라도 배열로 리턴되기 때문에 배열로 query key로 사용했습니다.

  • 두번째 인자값은 query function인데 fetching 함수를 넣으면 됩니다. 리턴값은 Promise를 리턴해야 합니다.

  • 세번째 인자값은 옵션값인데 getNextPageParam의 리턴값으로 더 불러올 데이터가 있는지 없는지 판단을 한다. falsy값을 리턴하면 fetch function을 실행 하지 않는다. 리턴값은 fetch callback pageParam의 인자값으로 전달된다.

4. useEffect, react-intersection-observer를 이용하여 구현

 const { ref, inView } = useInView({ threshold: 0.3 }); 
// ref는 target을 지정할 element에 지정한다. 
//inView type은 boolean으로 root(뷰포트)에 target(ref를 지정한 element)이 들어오면 true로 변환됨

 useEffect(() => {
   // hasNextPage 다음 페이지가 있는지 여부, Boolean (getNextPageParam 리턴값에 의해서)
    if (inView && hasNextPage) {
      // fetchNextPage fetch callback 함수를 실행
      fetchNextPage();
    }
  }, [inView]);

5. 리턴부 구현


  return (
    <>
      <div>
        <h1>Infinite Scroll</h1>
        {status === 'loading' ? (
          <p>Loading...</p>
        ) : status === 'error' ? (
          <span>Error:</span>
        ) : (
          <>
            {data?.pages?.map((page, i) => (
              <React.Fragment key={i}>
                {page.items.map((project) => (
                  <p
                    style={{
                      border: '1px solid gray',
                      borderRadius: '5px',
                      padding: '10rem 1rem',
                      background: `hsla(${project.id * 30}, 60%, 80%, 1)`,
                    }}
                    key={project.id}
                  >
                    {project.name}
                  </p>
                ))}
              </React.Fragment>
            ))}
            <div>
			// button에 useInView의 ref를 넣었다 해당 컴포넌트가 뷰포트에 보이면 fetch callback function이 실행 된다.
              <button ref={ref} onClick={() => fetchNextPage()} disabled={!hasNextPage || isFetchingNextPage}>
                {isFetchingNextPage ? 'Loading more...' : hasNextPage ? 'Load Newer' : 'Nothing more to load'}
              </button>
            </div>
            <div>{isFetching && !isFetchingNextPage ? 'Background Updating...' : null}</div>
          </>
        )}
        <hr />
      </div>
    </>
  );
}

6. 결론

App.tsx

import React, { MouseEvent, useEffect, useState } from 'react';
import { QueryClient, QueryClientProvider, useInfiniteQuery, useMutation, useQuery } from 'react-query';
import { useInView } from 'react-intersection-observer';
import { ReactQueryDevtools } from 'react-query/devtools';
import axios from 'axios';
import { getUsers, addUser } from './api/public';
import './App.css';
const queryClient = new QueryClient();

export interface Owner {
  login: string;
  id: number;
  node_id: string;
  avatar_url: string;
  gravatar_id: string;
  url: string;
  html_url: string;
  followers_url: string;
  following_url: string;
  gists_url: string;
  starred_url: string;
  subscriptions_url: string;
  organizations_url: string;
  repos_url: string;
  events_url: string;
  received_events_url: string;
  type: string;
  site_admin: boolean;
}

export interface License {
  key: string;
  name: string;
  spdx_id: string;
  url: string;
  node_id: string;
}

export interface Item {
  id: number;
  node_id: string;
  name: string;
  full_name: string;
  private: boolean;
  owner: Owner;
  html_url: string;
  description: string;
  fork: boolean;
  url: string;
  forks_url: string;
  keys_url: string;
  collaborators_url: string;
  teams_url: string;
  hooks_url: string;
  issue_events_url: string;
  events_url: string;
  assignees_url: string;
  branches_url: string;
  tags_url: string;
  blobs_url: string;
  git_tags_url: string;
  git_refs_url: string;
  trees_url: string;
  statuses_url: string;
  languages_url: string;
  stargazers_url: string;
  contributors_url: string;
  subscribers_url: string;
  subscription_url: string;
  commits_url: string;
  git_commits_url: string;
  comments_url: string;
  issue_comment_url: string;
  contents_url: string;
  compare_url: string;
  merges_url: string;
  archive_url: string;
  downloads_url: string;
  issues_url: string;
  pulls_url: string;
  milestones_url: string;
  notifications_url: string;
  labels_url: string;
  releases_url: string;
  deployments_url: string;
  created_at: Date;
  updated_at: Date;
  pushed_at: Date;
  git_url: string;
  ssh_url: string;
  clone_url: string;
  svn_url: string;
  homepage: string;
  size: number;
  stargazers_count: number;
  watchers_count: number;
  language: string;
  has_issues: boolean;
  has_projects: boolean;
  has_downloads: boolean;
  has_wiki: boolean;
  has_pages: boolean;
  forks_count: number;
  mirror_url?: any;
  archived: boolean;
  disabled: boolean;
  open_issues_count: number;
  license: License;
  allow_forking: boolean;
  is_template: boolean;
  topics: string[];
  visibility: string;
  forks: number;
  open_issues: number;
  watchers: number;
  default_branch: string;
  score: number;
}

export interface IRepository {
  total_count: number;
  incomplete_results: boolean;
  items: Item[];
}

function InfiniteScroll() {
  const { ref, inView } = useInView({
    threshold: 0.3,
  });

  const fetchRepositories = async (page: number) => {
    return await axios
      .get<IRepository>(`https://api.github.com/search/repositories?q=topic:reactjs&per_page=30&page=${page}`)
      .then((resp) => resp.data);
  };

  const { data, status, hasNextPage, fetchNextPage, isFetchingNextPage, isFetching } = useInfiniteQuery<
    IRepository,
    Error,
    IRepository,
    [string] | string
  >(
    ['projects'],
    async ({ pageParam = 1 }) => {
      return await fetchRepositories(pageParam);
    },
    {
      getNextPageParam: (lastPage, allPages) => {
        const maxPage = lastPage.total_count / 30;
        const nextPage = allPages.length + 1;
        return nextPage <= maxPage ? nextPage : undefined;
      },
    }
  );

  React.useEffect(() => {
    if (inView && hasNextPage) {
      fetchNextPage();
    }
  }, [inView]);

  return (
    <>
      <div>
        <h1>Infinite Loading</h1>
        {status === 'loading' ? (
          <p>Loading...</p>
        ) : status === 'error' ? (
          <span>Error:</span>
        ) : (
          <>
            {data?.pages?.map((page, i) => (
              <React.Fragment key={i}>
                {page.items.map((project) => (
                  <p
                    style={{
                      border: '1px solid gray',
                      borderRadius: '5px',
                      padding: '10rem 1rem',
                      background: `hsla(${project.id * 30}, 60%, 80%, 1)`,
                    }}
                    key={project.id}
                  >
                    {project.name}
                  </p>
                ))}
              </React.Fragment>
            ))}
            <div>
              <button ref={ref} onClick={() => fetchNextPage()} disabled={!hasNextPage || isFetchingNextPage}>
                {isFetchingNextPage ? 'Loading more...' : hasNextPage ? 'Load Newer' : 'Nothing more to load'}
              </button>
            </div>
            <div>{isFetching && !isFetchingNextPage ? 'Background Updating...' : null}</div>
          </>
        )}
        <hr />
      </div>
    </>
  );
}

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <ReactQueryDevtools initialIsOpen={false} />
      <InfiniteScroll />
    </QueryClientProvider>
  );
}

export default App;
profile
기록의 중요성

0개의 댓글