[Next JS] next.js react query 무한스크롤

soonmuu·2023년 1월 20일
0

next

목록 보기
1/1
post-thumbnail

react query의 useInfiniteQuery를 사용해서 무한스크롤을 구현할 수 있다

0. react query 설치

npm i react-query

1. api 데이터 준비

useInfiniteQuery가 동작하기 위해서 넘겨받을 api에 필요한 값은 아래 3가지이다.

  1. 현재 페이지
  2. 다음페이지 판단여부
  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: [...]
}

2. api fetch

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()
}

3. useInfiniteQuery 사용

  • 필요한 변수를 가져온다
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;
}
  • useInfiniteQuery 전체코드
  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,
    }
  )

4. 로딩, 에러처리

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 (
    // 최종 렌더링

5. 스크롤이벤트

  • 별도의 패키지 설치 없이 간단한 스크롤 이벤트를 구현하였다
  • 실제프로젝트에서는 여러 페이지에서 사용되기 때문에 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])

6. 최종 ui 출력

  • 데이터구조
    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>
)

7. 전체코드

// 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
profile
프론트엔드

0개의 댓글