본문 바로가기
웹/(React)리액트

(react-query)useInfiniteQuery로 무한스크롤 구현하기 (상세 설명, React with Typescript)

by 공부가싫다가도좋아 2024. 10. 29.
반응형

포스팅 목차

1. 구현 완료 후 영상

2. 필요한 라이브러리 정리

3. 코드와 함께 상세 설명

4. 전체 코드 및 주석


1. 구현 완료 후 영상

 

* 해당 영상은 "네트워크 > 느린 4G" 환경에서 찍은 영상입니다.(데이터를 불러올 때의 UI도 보여드리기 위함)

 


2. 필요한 라이브러리 정리

  "@tanstack/react-query": "^5.59.16",

 

설치 방법

npm i @tanstack/react-query

 

tanstack/react-query 공식 문서 : https://tanstack.com/query/latest/docs/framework/react/installation

 


3. 코드와 함께 상세 설명

1. Request&Response 값 타입 정의

backend에서 받아오는 response 값과 request 값의 타입은 아래와 같습니다. (해당 코드에서는 필요한 값들만 정의했습니다.)

 

interface TContents {
  board_id: number;
  title: string;
}

// 응답 값 타입 정의
interface GetBoardListResponse {
  status: "SUCCESS" | "FAIL";
  message: string;
  body: {
    contents: TContents[];
    page: number;
    hasNext: boolean;
  };
}

// 요청 값 타입 정의
interface GetBoardListRequest {
  page: number;
  pageSize: number;
}

 


2. backend에 요청을 보낼 api 정의

data : 백엔드에게 요청해서 받아온 응답 값

fetchNextPage: 다음 페이지 값을 가져오도록 하는 함수.

isFetchingNextPage: fetchNextPage로 다음 페이지를 가져오는 동안 true 반환.

queryKey: query key를 통해 데이터를 캐싱함.

queryFn: Promise를 반환하는 임의의 함수. Promise는 데이터나 에러를 반환한다. useInfiniteQuery에서는 getNextPageParam에서 반환한 값(pageParam)을 인수로 받는다.

getNextPageParam: query에 대한 새로운 데이터가 수신될 때 호출되며, 리스트의 마지막 페이지와 모든 페이지의 정보를 인수로 받는다. 해당 함수에서는 queryFn에 전달할 하나의 변수를 반환해야 한다. 또한, 다음 페이지가 없음을 나타내려면 undefined을 반환해야 한다.

initialPageParam: 초기 pageParam값 정의.

select: queryFn에서 반환한 data값을 가공하여 반환할때 사용함.

 

  const { data, fetchNextPage, isFetchingNextPage } = useInfiniteQuery({
    queryKey: ["data"],
    queryFn: async ({ pageParam }) => {
      const params: GetBoardListRequest = { page: pageParam, pageSize: 10 };
      const res = await axios.get<GetBoardListResponse>(
        "http://localhost:8080/board/pagination",
        { params }
      );
      if (res.data.status === "SUCCESS") {
        return res.data.body;
      }
      throw new Error(res.data.message || "board 불러오기 실패");
    },
    getNextPageParam: (data) => {
      if (data) {
        return data.hasNext ? data.page + 1 : undefined;
      }
      return undefined;
    },
    initialPageParam: 0,
    select: (data) => {
      const contentPage = data.pages.flatMap((aPage) => aPage.contents);
      return {
        content: contentPage,
        pageParams: data.pageParams,
      };
    },
  });

 


3. 다음 페이지를 불러오기 위해 교차점으로 활용할 target 설정

<div ref={targetRef} /> : fetchNextPage()함수를 불러올 교차점. 리스트 맨 아래 위치하게 하여, 사용자 화면에서 해당 div박스가 보일 때를 '교차했다' 라고합니다.

intersectionObserver.observe(targetRef.current) : 관찰할 대상을 지정합니다.

new IntersectionObserver : 관찰 대상은 entries배열에 들어가 있으며, 우리의 관찰 대상은 하나뿐이므로 바로 entries[0]을 관찰하면 됩니다(우리가 관찰하는 대상 = entries[0]). 그러므로, 관찰대상을 교차했을 경우 entries[0].isIntersecting = true가 되므로 fetchNextPage()함수 실행되여 다음 페이지의 데이터를 불러옵니다.

 

const App = () => {
	const targetRef = React.useRef<HTMLDivElement>(null);
    
    {/* api 요청하는 useInfiniteQuery 위에서 설명하였으므로 생략 */}
    
	React.useEffect(() => {
        // 관찰자
        const intersectionObserver = new IntersectionObserver((entries) => {
          if (entries[0].isIntersecting) fetchNextPage();
        });
        if (targetRef && targetRef.current) {
          intersectionObserver.observe(targetRef.current);
        }

        return () => {
          if (targetRef.current) {
            intersectionObserver.unobserve(targetRef.current);
          }
        };
      }, [targetRef, fetchNextPage]);
      
	return (
        <div>
         {*/ ... 리스트들 불러오는 코드 생략*/}
          {isFetchingNextPage && (
            <div
              style={{
                {*/ ... style 생략*/}
              }}
            >
              <h1>Loading..</h1>
            </div>
          )}
          <div ref={targetRef} />
        </div>
      );
   };

 

IntersectionObserver 에 대해서 : https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver

 


4. 전체 코드 및 주석

import "./App.css";
import axios from "axios";
import { useInfiniteQuery } from "@tanstack/react-query";
import React from "react";

interface TContents {
  board_id: number;
  title: string;
}

// 응답 값 타입 정의
interface GetBoardListResponse {
  status: "SUCCESS" | "FAIL";
  message: string;
  body: {
    contents: TContents[];
    page: number;
    hasNext: boolean;
  };
}

// 요청 값 타입 정의
interface GetBoardListRequest {
  page: number;
  pageSize: number;
}

const App = () => {
  const targetRef = React.useRef<HTMLDivElement>(null);

  //react-query api 함수
  const { data, fetchNextPage, isFetchingNextPage } = useInfiniteQuery({
    queryKey: ["data"],
    queryFn: async ({ pageParam }) => {
      const params: GetBoardListRequest = { page: pageParam, pageSize: 10 };
      const res = await axios.get<GetBoardListResponse>(
        "http://localhost:8080/board/pagination",
        { params }
      );
      if (res.data.status === "SUCCESS") {
        return res.data.body;
      }
      throw new Error(res.data.message || "board 불러오기 실패");
    },
    getNextPageParam: (data) => {
      if (data) {
        return data.hasNext ? data.page + 1 : undefined;
      }
      return undefined;
    },
    initialPageParam: 0,
    select: (data) => {
      const contentPage = data.pages.flatMap((aPage) => aPage.contents);

      return {
        content: contentPage,
        pageParams: data.pageParams,
      };
    },
  });

  React.useEffect(() => {
    // 관찰자
    const intersectionObserver = new IntersectionObserver((entries) => {
      if (entries[0].isIntersecting) fetchNextPage();
    });
    if (targetRef && targetRef.current) {
      intersectionObserver.observe(targetRef.current);
    }

    return () => {
      if (targetRef.current) {
        intersectionObserver.unobserve(targetRef.current);
      }
    };
  }, [targetRef, fetchNextPage]);
  return (
    <div>
      <ul>
        {data?.content.map((page) => (
          <li
            key={page.board_id}
            style={{
              marginBottom: "20px",
              borderBottom: `1px solid black`,
              listStyle: "none",
            }}
          >
            <p>게시판 id :{page.board_id}</p>
            <p>title: {page.title}</p>
          </li>
        ))}
        <div />
      </ul>
      {/* 해당 div 박스가 보일 때, 즉 스크롤 제일 하단일 때, 
      IntersectionObserver를 통해 해당 요소에 접근했다는 것을 알림. 
      해당 요소에 접근 했을 경우 entries[0].isIntersecting = True*/}
      {isFetchingNextPage && (
        <div
          style={{
            background: "black",
            color: "white",
            height: "200px",
            display: "flex",
            justifyContent: "center",
            alignItems: "center",
          }}
        >
          <h1>Loading..</h1>
        </div>
      )}
      <div ref={targetRef} />
    </div>
  );
};

export default App;

 


해당 코드에서는 api를 작성하는 로직과 화면을 구현하는 로직을 한 페이지에서 작성하였지만, 
일반적으로 api코드와 화면 구현 코드는 분리해서 작업하는게 코드 유지보수에 더 좋습니다!

반응형

댓글