react-query를 이용하면 infinite-scroll을 보다 쉽게 구현을 할 수 있습니다.
react-intersection-observer를 이용하여 infinite-scroll를 구현하는데 내부적으로 IntersectionObserver API를 사용을 합니다. 잘 모른다면 Intersection Observer API 알아보기
- typescript
- react
- axios
- react-query
- react-intersection-observer
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를 사용한다고 합니다.)
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 사이트를 이용하면 시간 절약에 도움이 됩니다.
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의 인자값으로 전달된다.
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]);
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>
</>
);
}
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;