TanStack을 선택한 이유와 Quickstart 는 이전 포스트를 참고하자.
기존에는 HTTP 통신에 반복적으로 사용되는 작업을 재사용하기 위해 커스텀 훅을 만들어 사용했다.
React-Query를 도입하면서 HTTP 통신 관련 부분을 모두 마이그레이션 했는데, 그 중 가장 많은 부분이 변경된 Products 컴포넌트를 중심으로 기록할 예정이다.
서버에 있는 products 데이터는 paginated 되어 있다. '더 많은 상품 보기' 를 누르면 다음 페이지 데이터를 fetch 해온다.
참고 Pagination vs. Infinite Scroll vs. Load More Explained
검색어를 입력하면 API/products
에 있는 데이터를 다음과 같은 로직으로 fetch 해왔다. 해당 데이터는 paginated 된 데이터로 검색어 (keyword) 정보와 페이지 (page) 정보를 파라미터로 가진다.
query
와 페이지 정보 page
, fetch 해 온 데이터인 products
를 관리했다.response
isLoading
error
sendRequest
를 관리했다.query
의 변경을 감지하여 page=1 의 request를 보낸다.page + 1
의 request를 보낸다.isLoading
의 변경을 감지하여 fetch 해온 데이터를 products
에 추가한다.특정 위치에 도달했을 때 다음 페이지 데이터를 fetch 해오는 infinite scroll 방식은 아니지만, 버튼을 사용해 기존 데이터에 새로운 데이터를 fetch 해오기 때문에 react-query의 useInfiniteQuery
를 사용하였다.
기존에 이 부분을 직접 구현하면서 삽질을 꽤 했었다. 다른 페이지로 이동하고 다시 돌아왔을 때 중복으로 fetch가 되거나, StrictMode 일 때 중복으로 fetch 되는 등 Context API + useEffect 방식에 어려움을 겪었었다. 어찌 저찌 해서 해당 이슈들을 다 해결했지만 (위에서 언급한 기존 방식은 해결된 로직이다.) 왠지 모르게 잘못된 방식일 것 같다는 생각이 들었다.
아무튼 react-query의 useInfiniteQuery
를 사용하여 다음과 같이 구현했다.
interface 정의
export interface ProductsParamsModel {
query: string;
pageParam: number;
}
export interface ProductsResponseModel {
products: ProductModel[];
page: number;
lastPage: number;
total: number;
}
API 처리 함수 정의
const api = axios.create({
baseURL: process.env.API_URL,
});
export const getProducts = async (params: ProductsParams) => {
const response = await api('', {
params: {
keyword: params.query,
page: params.pageParam
},
});
const data: ProductsResponse = {
...response.data,
lastPage: response.data.last_page,
};
return data;
}
const { query } = useContext(ProductContext); // 검색어
const {
data,
isLoading,
isFetchingNextPage,
hasNextPage,
fetchNextPage
} = useInfiniteQuery({
queryKey: ['products', query],
queryFn: ({ pageParam }) => getProducts({ pageParam, query }),
initialPageParam: 1,
getNextPageParam: (prevPage, pages) => {
const isLastPage =
prevPage.products.length < 15 || prevPage.lastPage === prevPage.page;
return isLastPage ? undefined : pages.length + 1;
},
enabled: query.trim().length > 0,
staleTime: 300000,
});
useInfiniteQuery 옵션
queryFn
(required) : data 요청에 사용될 함수로 반드시 Promise를 반환해야 한다.initialPageParam
(required) : 처음으로 fetch해 올 page 값getNextPageParam
(required) : infiniteScroll 의 마지막 페이지 데이터, 모든 페이지 데이터, pageParam 정보를 받는다. queryFn
의 파라미터로 넘길 값을 return 한다. return 값이 undefined
나 null
일 경우, 다음 페이지가 없음을 의미한다.enabled
: false
일 경우 queryFn
이 실행되지 않는다.staleTime
: refresh 시킬 시간useInfiniteQuery가 반환하는 데이터
data.pages
: 모든 페이지의 데이터 (배열)data.pageParams
: 모든 pageParams (배열)isFetchingNextPage
: fetchNextPage
로 다음 페이지 데이터를 fetch 해오고 있는지 여부fetchNextPage
: 다음 페이지 데이터 결과를 fetch 해오는 함수 hasNextPage
: 다음 페이지를 fetch 해올 수 있는지 여부 자세한 내용은 공식 문서의 API Reference 를 확인하자.
좋았던 점 1. 캐싱 기능
캐싱 기능은 구현하지 않았었는데, 리액트 쿼리를 사용해서 쉽게 구현이 가능했다. 캐시를 사용해서 불필요한 request를 줄여 서버의 부담을 조금이나마 줄일 수 있었고 로딩을 기다릴 필요가 없으니 사용자 경험도 개선할 수 있었다.
좋았던 점 2. 전역에서 접근 가능한 데이터
import { queryClient } from '../../App';
const data = queryClient.getQueryData(queryKey)
위와 같이 QueryClient
를 사용해서 쿼리 키에 해당하는 데이터에 접근할 수 있다. 모든 컴포넌트에서 사용 가능하기 때문에 props drilling을 피할 수 있다.
좋았던 점 3. 만족스러운 DX
일단 코드가 많이 줄었고 직접 구현하지 않아도 되니 너무 편하다. 인터페이스를 사용해서 타입을 지정할 경우 접근할 수 있는 프로퍼티들도 뜨고.. 이건 타입스크립트의 편리함이긴 하지만.. 어쨌든 편하다..
해볼 것
React Router + React Query 도 살펴보긴 했는데, React Router (v6) 의 loader, action을 사용하는 것이 과연 좋은 것인가 라는 생각이 들었다. React Router의 기능은 최소한으로 사용하는 것이 좋다는 글을 봤어서 일단 이 부분은 더 고민이 필요할 것 같다.
우선적으로 해볼 것은 Context API가 아닌 다른 상태 관리 라이브러리를 도입하고 React Query와 함께 사용해 볼 생각이다.