기록하는 개발자

[NextJs] React-Query의 useInfiniteQuery로 무한스크롤 구현하기 본문

Web/NextJs

[NextJs] React-Query의 useInfiniteQuery로 무한스크롤 구현하기

밍맹030 2023. 5. 17. 21:06
728x90

nextjs+typescript+tailwindcss 환경에서 영화 api를 사용한 토이 프로젝트를 진행중이다.

(영화 api https://developer.themoviedb.org/reference/intro/getting-started)

https://mingmeng030.tistory.com/270

 

[NextJs] NextJs+Typescript로 프로젝트 시작하기

1. Node 설치 - Node 설치가 되어있지 않은 경우 Node 부터 설치 - 이미 Node가 설치되어 있는 경우 2번부터 수행 // node version 확인 node -v [ Mac OS ] - 1) homebrew설치(없는경우) /usr/bin/ruby -e "$(curl -fsSL https://r

mingmeng030.tistory.com

https://mingmeng030.tistory.com/275

 

[NextJs] NextJs에 TailwindsCss 적용 하기

https://tailwindcss.com/ Tailwind CSS - Rapidly build modern websites without ever leaving your HTML. Tailwind CSS is a utility-first CSS framework for rapidly building modern websites without ever leaving your HTML. tailwindcss.com 1. 프로젝트 생성

mingmeng030.tistory.com

프로젝트 홈 화면

프로젝트의 nav bar에서는 키워드를 통해 영화 검색을 할 수 있는데,

아래와 같은 검색 결과 화면을 무한스크롤을 통해 보여주기 위해

react-query의 useInfiniteQuery를 사용해보기로 했다.

1. react-query 설치

  npm i axios react-query

2. _app.js 수정

import { QueryClient, QueryClientProvider } from "react-query";
import { ReactQueryDevtools } from "react-query/devtools";
import "../styles/globals.css";
import Layout from "../components/Layout";

export default function App({ Component, pageProps }) {
  const queryClient = new QueryClient();

  return (
  // QueryClientProvider로 인해 모든 페이지 및 컴포넌트에서 queryClient에 접근 가능
    <QueryClientProvider client={queryClient}>
      <Layout>
        <Component {...pageProps} />
      </Layout>
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

3. 무한스크롤을 사용할 파일

- 아래와 같이 import 후 시작하였다.

import { useInfiniteQuery } from "react-query";
import { useRef } from "react";
import axios from "axios";

(1) api 호출 함수 작성

//호출할 api 함수로 작성
const fetchMovies = ({ pageParam = 1 }: type.fetchMovieProps) =>
  axios
    .get(`${config.api}/api/search/${keywordToShow}/${pageParam}`)
    .then((res) => {
      return res;
    });

api 호출 결과 response

- data 객체 내 현재 페이지 번호(page) , 영화 리스트(results), 전체 페이지 수(total_pages), 전체 결과 수(total_results)가 있다.

 

(2) useInfiniteQuery 작성

  const { 
    data, // api 함수 호출 결과 response와 pages, pageParams가 담긴 배열
    fetchNextPage, // 다음 페이지를 불러오는 함수
    status // loading, error, success 중 하나의 상태, string
  } = useInfiniteQuery(
    ["movielist"], // data 이름(queryKey)
    fetchMovies, // fetch callback : 작성한 api 호출 함수
    {
      getNextPageParam: (lastPage) => {
        const page = lastPage.data.page;
        if (lastPage.data.total_pages == page) return false;
        return page + 1;
      },
    }
  );

data

3페이지까지 보이는 경우의 data의 모습

pages

fetch callback을 통해 return된 page 객체의 배열을 갖고 있다.

 

pageParams

useInfiniteQuery가 현재 어떤 페이지에 있는지를 확인할 수 있는 파라미터 값

 

getNextPageParam

getNextPageParam: (lastPage) => {
  const page = lastPage.data.page; // 현재 페이지
  // 현재 페이지와 전체 페이지수가 동일하면 false return 
  // -> 더 이상 fetch api 실행 안하고 무한 스크롤 종료
  if (lastPage.data.total_pages == page) return false;
  return page + 1;
},

getNextPageParam 함수가 false 를 return하면 반환하면 api 호출이 더 이상 실행되지 않는다.(무한스크롤 종료)

false를 return하지 않는 경우에는 Number를 리턴해야 하고, 다음 page 를 return 하도록 한다.

(Number를 return 하는 경우 자동으로 fetch callback의 인자로 pageParam을 전달한다.)

 

내가 호출하는 api의 res 구조는 data안에 page라는 parameter가 있기 때문에

lastPage.data.page로 접근하였다. (lastPage : 이전 페이지 객체)

현재 페이지가 전체 페이지수와 같으면 false를 return하고 그렇지 않은 경우 현재 page+1을 return 한다.

(3) IntersectionObserver

사용자가 스크롤을 맨 밑으로 내렸을 때 브라우저가 이를 감지하고 자동으로 fetchNextPage 함수를 작동 시킨다면 무한 스크롤이 가능해질 것이다.

IntersectionObserver는 접촉이 감지되었을 때 실행할 callback 함수와 접촉을 감지할 조건을 option으로 가진다.

const oberserver = new IntersectionObserver(([entry]) => onIntersect() , { ...option } )

 

useObserver.js

import { useEffect } from "react";

export const useObserver = ({
  target, // 감지할 대상(여기서는 ref를 전달했다.)
  root = null, // 교차할 부모 요소(default : document)
  rootMargin = "0px", // root와 target이 감지하는 여백의 거리
  threshold = 1.0, // 임계점으로, 1.0이면 root내에서 target이 100% 보여질 때 callback이 실행된다.
  onIntersect, // target 감지 시 실행할 callback 함수
}) => {
  useEffect(() => {
    let observer;

    // 넘어오는 element가 있어야 observer를 생성
    if (target && target.current) {
      // callback의 인자로 들어오는 entry는 기본적으로 순환자이기 때문에
      // 복잡한 로직을 필요로 할때가 많다.
      // callback을 선언하는 곳에서 로직을 짜서 통째로 넘기도록 하겠다.
      observer = new IntersectionObserver(onIntersect, {
        root,
        rootMargin,
        threshold,
      });
      // 실제 Element가 들어있는 current 관측 시작
      observer.observe(target.current);
    }
    // observer를 사용하는 컴포넌트가 해제되면 observer도 끈다
    return () => observer && observer.disconnect();
  }, [target, rootMargin, threshold]);
};

useObserver 호출 부분

onIntersect

useObserver로 넘겨줄 callback 이다.

entry로 넘어오는 HTMLElement가 isIntersecting이라면 무한 스크롤을 위한 fetchNextPage가 실행된다.

const onIntersect = ([entry]) => entry.isIntersecting && fetchNextPage();

useObserver({
  target: bottom,
  onIntersect,
});

 

전체 코드

import { useInfiniteQuery } from "react-query";
import { useObserver } from "./useObsever";
import { useRef } from "react";
import axios from "axios";
import { useRouter } from "next/router";
import { config } from "../../static/config";

import Seo from "../../components/Seo";
import styles from "../../styles/Search.module.css";
import Link from "next/link";
import * as type from "./types";
import * as commonType from "../../types/commonType";

export default function searchResult() {
  const router = useRouter();
  const regex = /[\s\{\}\[\]\/?.,;:|\)*~`!^\-_+<>@\#$%&\\\=\(\'\"]+/g;
  const keywordToShow = router.query.params[0].replace(/[+]/g, " ");
  
  // 화면의 바닥 ref를 위한 useRef
  const bottom = useRef(null);

  const fetchMovies = ({ pageParam = 1 }: type.fetchMovieProps) =>
    axios
      .get(`${config.api}/api/search/${keywordToShow}/${pageParam}`)
      .then((res) => {
        console.log(res);
        return res;
      });

  const { data, fetchNextPage, status, refetch } = useInfiniteQuery(
    ["movielist"],
    fetchMovies,
    {
      getNextPageParam: (lastPage) => {
        const page = lastPage.data.page;
        if (lastPage.data.total_pages == page) return false;
        return page + 1;
      },
    }
  );

  const onIntersect = ([entry]) => entry.isIntersecting && fetchNextPage();

  useObserver({
    target: bottom,
    onIntersect,
  });

  return (
    <div className="margincenter w-4/5">
      <Seo title={"search result"}></Seo>
      {status === "loading" && <p>불러오는 중</p>}
      {status === "error" && <p>불러오기 실패</p>}
      {status === "success" && data && (
        <>
          <p>"{keywordToShow}" 검색 결과 입니다.</p>
          <p>총 {data.pages[0].data.total_results}개의 검색 결과가 있습니다.</p>

          <div className="flexwrap">
            {data.pages?.map((page) => {
              const movieList: commonType.apiResult[] = page.data.results;
              return movieList.map((movie) => {
                return (
                  <Link>...</Link>
                );
              });
            })}
          </div>
        </>
      )}
      // 바닥 ref를 위한 div 생성
      <div ref={bottom} />
    </div>
  );
}

결과 화면

 

참고

https://tanstack.com/query/v3/

https://velog.io/@hdpark/React-Query%EC%99%80-%ED%95%A8%EA%BB%98%ED%95%98%EB%8A%94-Next.js-%EB%AC%B4%ED%95%9C-%EC%8A%A4%ED%81%AC%EB%A1%A4

728x90