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();
'JS > React' 카테고리의 다른 글
React - Rendering 성능 향상 시키기 (0) | 2024.03.09 |
---|---|
React - Rendering에 대하여 (0) | 2024.03.09 |
React - 이벤트 핸들러 효율적으로 처리하기 (0) | 2024.03.09 |
React - Intersection Observer API (0) | 2024.03.09 |
React - ESLint: TypeError: this.libOptions.parse is not a function (0) | 2024.03.09 |