JS/React

React - 무한 스크롤(Infinite Scroll) 구현하기

shin96bc 2024. 3. 9. 22:29

Scroll Event(Feat. throttle)

기존에는 Scroll Event를 사용해서 구현했었지만 여러가지 문제들이 많아서 권장하지 않습니다.

 

Intersection Observer API(권장) (Feat. React-query)

Intersection Observer API 사용법

 

무한 스크롤 구현 예시

 

저는 여러 페이지에서 무한 스크롤을 사용하기 위해서 custom hook으로 분리했습니다.

 

useIntersectionObserver.ts

import {useCallback, useEffect, useRef} from "react";

// [Intersection Observer API custom hook]
// observer를 매 페이지마다 생성하지 않도록 target으로 지정한 ref 자체를 반환해서
// 모든 페이지에서 최대한 간단하게 사용할 수 있도록 설계했습니다.
const useIntersectionObserver = (
    onIntersect: (entry: IntersectionObserverEntry, observer: IntersectionObserver) => void,
    options: IntersectionObserverInit
) => {
    // target 요소를 지정하기 위한 ref
    const ref = useRef(null);

    // target이 교차 상태가 되면 onIntersect 함수를 실행하는 useCallback 함수
    const callback = useCallback((entries: IntersectionObserverEntry[], observer: IntersectionObserver): void => {
        entries.forEach((entry: IntersectionObserverEntry) => {
            // target이 교차 상태가 되면 onIntersect 함수를 실행
            // isIntersecting가 true이면 교착 상태
            if (entry.isIntersecting) onIntersect(entry, observer);
        })
    }, [onIntersect]);

    // useEffect에서 IntersectionObserver 객체를 생성하고 observer 호출을 통해서 target 요소의 관찰을 시작
    useEffect(() => {
        if (!ref.current) return;

        const observer: IntersectionObserver = new IntersectionObserver(callback, options);
        observer.observe(ref.current);
        // 컴포넌트가 unmount되면 observer의 disconnect 함수로 모든 관찰을 중지
        return () => observer.disconnect();
    }, [ref, options, callback]);

    return ref;
};

export default useIntersectionObserver;

 

useFetchData.ts

import {useInfiniteQuery, UseInfiniteQueryResult} from "react-query";
import axios from "axios";

// arg의 타입
type Argument = {
    url: string;
    params: any
};

// queryKey(react query가 cache에 접근하기 위한 고유한 key)
const dataKeys = {
    all: ['data'],
    lists: () => [...dataKeys.all, 'lists'],
};

// [React Query를 사용한 API 호출 함수]
// useInfiniteQuery(queryKey, queryFn)
const useFetchData = ({url, params}: Argument): UseInfiniteQueryResult => {
    return useInfiniteQuery(
        dataKeys.lists(),
        ({pageParam = 1}) => {
            return axios.get(process.env.REACT_APP_REST_API + url, {
                params: {
                    // 넘겨줘야할 데이터를 작성
                    ...params,
                    page: pageParam,
                },
                headers: {
                    // jwt token이 있다면 Authorization 작성
                },
            })
        },
        {
            // useInfiniteQuery의 반환값에 포함된 fetchNextPage() 함수를 호출하면 실행되는 함수입니다.
						// 여기서 반환해준 값을 다시 queryFn의 인자값으로 넘겨줘서 다시한번 API를 호출하게 됩니다.
            getNextPageParam: ({data: {response}}, allPages) => {
                // [가정] API에서 넘겨준 데이터가 {status: 200, message: 'OK', response: {page: 1, list: []}} 라고 가정

                // [방법1] API에서 페이지 번호를 넘겨 준다면 넘겨받은 페이지 번호로 처리
                return !response || response.list.length < 1 ? undefined : response.page + 1;

                // [방법2] API에서 페이지 번호를 넘겨받을 수 없다면 두번째 인자값으로 들어오는 allPages를 사용
                // allPages에는 API에서 받은 데이터를 캐싱해둔 배열이 들어옵니다. 그래서 배열의 길이로 몇 페이지까지 진행되었는지 알 수 있습니다.
                return !response || response.list.length < 1 ? undefined : allPages.length + 1;
            }
        }
    );
};

export default useFetchData;

 

queryClient.ts

import {QueryClient} from "react-query";

// react-query를 사용하기 위해서 QueryClient 객체를 생성합니다.
// 생성한 QueryClient객체는 QueryClientProvider에 넘겨줍니다.
const queryClient: QueryClient = new QueryClient({
		// 이렇게 원하는 옵션 설정도 가능합니다.
    defaultOptions: {
        queries: {
            // 설정 가능한 옵션 목록
            refetchOnWindowFocus: false, //default: true
            refetchOnMount: true,        //default: true
            refetchOnReconnect: true,    //default: true
            staleTime: 0,                //default: 0
            cacheTime: (60 * 5 * 1000)   //default: 5분 (60 * 5 * 1000)
        }
    }
});

export default queryClient;

 

App.tsx

import React, {useMemo} from 'react';
import logo from './logo.svg';
import './App.css';
import useFetchData from "./hook/useFetchData";
import useIntersectionObserver from "./hook/useIntersectionObserver";

type Product = {
  idx: number;
  name: string;
  price: number;
};

function App() {
  const {
      data, 
      hasNextPage,
      isFetching,
      isError,
      fetchNextPage, 
      refetch, 
      remove
  } = useFetchData({
      url: '/',
      params: {},
  });

  // target ref
  const targetRef = useIntersectionObserver(
      async (entry: IntersectionObserverEntry, observer: IntersectionObserver) => {
        observer.unobserve(entry.target);
        if (hasNextPage && !isFetching) {
          await fetchNextPage();
        }
      }
  );

	// useInfiniteQuery의 반환값인 data는 page 별로 응답 데이터가 누적되는 형태입니다.
	// 그래서 useMemo를 통해 평탄화 작업을 할 수 있도록 작성했습니다.
  const dataList = useMemo<Product[] | []>(
      () =>
          (data ? data.pages.flatMap((page: any): Product[] | [] => page.data.response.list) : []),
      [data]
  );

  return (
    <div className="App">
        <div>
            {
                dataList.map((data) => {
                    return <div>{data.name}</div>
                })
            }
        </div>
        <div ref={targetRef} style={{height: 1}}></div>
    </div>
  );
}

export default App;

 

index.tsx

import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import {QueryClientProvider} from "react-query";
import queryClient from "./query/queryClient";

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);

root.render(
		<QueryClientProvider client={queryClient}>
        <App />
    </QueryClientProvider>
);

reportWebVitals();