react query의
useInfiniteQuery
를 사용해서 무한스크롤을 구현할 수 있다
npm i react-query
useInfiniteQuery
가 동작하기 위해서 넘겨받을 api에 필요한 값은 아래 3가지이다.
2번 다음페이지 판단여부는 서버에서 직접 전송받는 방법과 전송된 데이터를 이용해 판단하는 방법이 있다.
{
hasNextPage: false, // 다음페이지 여부
thisPage: 1, // 현재 페이지
data: [...]
}
다음 테스트 api에서는 다음페이지 판단 값이 없어서 total 값으로 판단할 수 있다
(해당 사이트에 로그인하면 api를 이용할 수 있는 app-id를 가질 수 있다)
https://dummyapi.io/data/v1/user?limit=10&page=${pageParam}
{
limit: 10,
page: 1,
total: 99,
data: [...]
}
const getList = async ({ pageParam = 1 }) => {
const url = `https://dummyapi.io/data/v1/user?limit=10&page=${pageParam}`
const res = await fetch(url, {
method: "GET",
headers: {
"app-id": "발급받은 app id"
}
})
return res.json()
}
const {
data, // 렌더링 할 데이터
status, // query 상태값 (로딩중, 에러 등)
error, // 에러발생시 respons
fetchNextPage, // 다음페이지 실행 함수
isFetchingNextPage, // 다음페이지 로딩중 판단
hasNextPage // 다음페이지 판단 여부
} = useInfiniteQuery( ... )
다음페이지가 있는지 서버에서 바로 전송이 될때는 lastPage.hasNextPage ? lastPage.page + 1 : false
와 같은 형태로 쓸 수있다
테스트 api에서는 전체 데이터 값을 줬기 때문에 전체값이
현재페이지 * 페이지당 데이터수보다 크면 페이지를 증가시켰다.
다음페이지가 없으면 false를 리턴하고 해당값은 위 변수의hasNextPage
값으로 받을 수 있다
getNextPageParam: (lastPage) => {
return lastPage.total > lastPage.page * lastPage.limit ? lastPage.page + 1 : false
},
interface IApiError {
message: string;
}
const {
data,
status,
error,
fetchNextPage,
isFetchingNextPage,
hasNextPage
} = useInfiniteQuery(
['getList'],
({pageParam = 1}) => getList({pageParam}),
{
onSettled: res => {
setLoadMore(true)
},
getNextPageParam: (lastPage) => {
return lastPage.total > lastPage.page * lastPage.limit ? lastPage.page + 1 : false
},
onError: (err: IApiError) => err,
}
)
status 값에 따라 로딩 스피너나 에러메시지를 출력하고 data fetch가 성공하면 최종 ui를 출력한다
if(status == 'loading') return <div className={styles.loading}><span className={styles.spinner}><RingLoader color="#aaa" /></span></div>
if(status == 'error') return <div>{error?.message}</div>
if(data == undefined) return <div>데이터가 정의되지 않았습니다</div>
return (
// 최종 렌더링
react-infinite-scroll-component
와 같은 컴포넌트를 사용하거나 직접 컴포넌트를 구현해 사용하는것이 더 좋다fetchNextPage
를 여러번 실행하면 오류발생)// 스크롤이벤트를 한번만 실행하기 위해 변수를 선언해준다
const [loadMore, setLoadMore] = useState(false);
const { ... } = useInfiniteQuery(
['getList'],
({pageParam = 1}) => getList({pageParam}),
{
onSettled: res => {
// 쿼리 실행시 다음 데이터 패치 실행 리셋
setLoadMore(true)
},
getNextPageParam: ...,
onError: ...,
}
)
const isScroll = () => {
let padding = 100
let scrollY = window.scrollY
let screenHeight = window.innerHeight
let bodyHeight = document.documentElement.offsetHeight
let scrollEnd = scrollY + screenHeight;
let pos = scrollEnd + padding
let isEnd = pos >= bodyHeight
// 스크롤이 맨끝에 도달했고 추가 패치를 실행하지않았다면 패치 실행
if(isEnd && !loadMore){
setLoadMore(true)
}
}
// 스크롤 이벤트 발생
useEffect(() => {
window.addEventListener('scroll', isScroll);
return () => window.removeEventListener('scroll', isScroll);
}, [])
// loadMore가 true로 변경될때 fetchNextPage 실행
useEffect(() => {
if(loadMore){
fetchNextPage()
}
}, [loadMore])
data?.pages
에서는 페이지를 리턴해준다pages: [
0: {...},
1: {...},
...
]
data?.pages
을 map으로 출력하며 각 페이지를 알맞은 ui로 출력할 수 있다
data?.pages.map((page) => (
page.data.map(({
id,
firstName,
lastName,
picture
}:listType): JSX.Element => (
<div key={id}>
<p>{firstName} {lastName}</p>
<img src={picture} width={100} />
</div>
))
데이터가 없을때 빈배열을 받는 경우가 있다면 첫번째 페이지 데이터로 데이터 여부를 판단한다
data?.pages[0].data.length
fetchNextPage
가 실행되어 다음페이지 데이터를 불러오는 중에는 isFetchingNextPage
로 로딩중을 판단 할 수 있다
hasNextPage
로 마지막페이지를 판단할 수 있다return (
<div>
{data?.pages[0].data.length > 0 ?
data?.pages.map((group, index) => (
group.data.map(({id, firstName, lastName, picture}:listType): JSX.Element => (
<div key={id}>
<p>{firstName} {lastName}</p>
<img src={picture} width={100} />
</div>
))
)): (<div>등록된 게시글이 없습니다</div>)}
// 다음페이지 불러올때 하단 로딩중 표시
{isFetchingNextPage && <div className={styles.center}><PulseLoader color="#ccc" /></div>}
// 마지막 페이지일때 표시
{!hasNextPage && <p className={styles.center}>마지막 페이지 입니다</p>}
</div>
)
// list.module.css
.loading{position: fixed; top: 0; left: 0; z-index: 9; width: 100%; height: 100%; background: rgba(255, 255, 255, 0.5); display: flex; align-items: center; justify-content: center;}
.spinner{display: inline-block; width: 60px;}
.center{text-align: center; padding: 20px 0;}
import React, { useEffect, useRef, useState } from 'react'
import { useInfiniteQuery } from 'react-query'
import { PulseLoader } from 'react-spinners';
import RingLoader from 'react-spinners/RingLoader';
import styles from "../components/list.module.css"
interface IApiError {
message: string;
description: string;
statusCode: string | number;
}
interface listType {
id: string,
firstName: string,
lastName: string,
picture: string,
}
const getList = async ({ pageParam = 1 }) => {
const url = `https://dummyapi.io/data/v1/user?limit=10&page=${pageParam}`
const res = await fetch(url, {
method: "GET",
headers: {
"app-id": "발급받은 app id"
}
})
return res.json()
}
const List = () => {
const [loadMore, setLoadMore] = useState(false);
const {
data,
status,
error,
fetchNextPage,
isFetchingNextPage,
hasNextPage
} = useInfiniteQuery(
['getList'],
({pageParam = 1}) => getList({pageParam}),
{
onSettled: res => {
setLoadMore(false)
},
getNextPageParam: (lastPage) => {
return lastPage.total > lastPage.page * lastPage.limit ? lastPage.page + 1 : false
},
onError: (err: IApiError) => err,
}
)
const isScroll = () => {
let padding = 100
let scrollY = window.scrollY
let screenHeight = window.innerHeight
let bodyHeight = document.documentElement.offsetHeight
let scrollEnd = scrollY + screenHeight;
let pos = scrollEnd + padding
let isEnd = pos >= bodyHeight
if(isEnd && !loadMore){
setLoadMore(true)
}
}
useEffect(() => {
window.addEventListener('scroll', isScroll);
return () => window.removeEventListener('scroll', isScroll);
}, [])
useEffect(() => {
if(loadMore){
fetchNextPage()
}
}, [loadMore])
if(status == 'loading') return <div className={styles.loading}><span className={styles.spinner}><RingLoader color="#aaa" /></span></div>
if(status == 'error') return <div>{error?.message}</div>
if(data == undefined) return <div>데이터가 정의되지 않았습니다</div>
return (
<div>
{data?.pages[0].data.length > 0 ?
data?.pages.map((group, index) => (
group.data.map(({id, firstName, lastName, picture}:listType): JSX.Element => (
<div key={id}>
<p>{firstName} {lastName}</p>
<img src={picture} width={100} />
</div>
))
)): (<div>등록된 게시글이 없습니다</div>)}
{isFetchingNextPage && <div className={styles.center}><PulseLoader color="#ccc" /></div>}
{!hasNextPage && <p className={styles.center}>마지막 페이지 입니다</p>}
</div>
)
}
export default List