오늘 한 일
- Navbar 안에 SearchInput 컴포넌트 구현.
- 검색시 books/:keyword의 주소를 가진 bookSearchResultPage 로 이동하도록 구현.
- 이동하면 useParams로 키워드를 받아와 fetchBooksData() 로 데이터 받아오기
- 각각의 책을 클릭하면 상세페이지로 이동(3번과 마찬가지로)하도록 구현
- 무한 스크롤 구현
4번까지는 아주아주 간단한 작업이기 때문에 생략.
데이터들을 무한 스크롤을 이용해서 구현해야겠다 생각하면서부터 일이 복잡해졌지만, 시작은 패기 넘쳤다.
무한 스크롤은 바닐라 자바스크립트로도 구현한 적이 있었다. Intersection API를 이용해서 관찰하고 있는 컴포넌트가 타겟에 도달하면(isIntersecting) 데이터를 fetch해서 뒤에 갖다 붙이는 방식으로 구현했었다. 그때는 자바스크립트만 사용했지만 지금 나에게는 리액트라는 강력한 라이브러리의 도움이 있기 때문에 '쉽겠지~' 하면서 호기롭게 시작을 했다.
결론적으로 말하자면 이상한 데서 삽질을 하다가 시간 다 보내고 Intersection API는 내일 마저 마무리 하려 한다. 무슨 삽질 했는지 정리하기!
@tanstack/react-query의 useinfiniteQuery() 로 무한 스크롤 구현하기 - 1
// useBooks.ts
import { useInfiniteQuery } from "@tanstack/react-query";
import { fetchBooksData } from "../api/aladin";
const useBooks = (keyword: string) => {
const {
fetchNextPage,
fetchPreviousPage,
hasNextPage,
hasPreviousPage,
isFetchingNextPage,
isFetchingPreviousPage,
isLoading,
isError,
isFetched,
data,
} = useInfiniteQuery({
queryKey: ["books", keyword],
queryFn: ({ pageParam }) => fetchBooksData(keyword, pageParam),
initialPageParam: 1,
getNextPageParam: (lastPage, allPages) => {
const nextPage = allPages.length + 1;
return lastPage?.data.length === 0 ? undefined : nextPage;
},
select: (data) => ({
pages: data?.pages.flatMap((page) => page.data),
pageParams: data.pageParams,
}),
});
return {
fetchNextPage,
fetchPreviousPage,
hasNextPage,
hasPreviousPage,
isFetchingNextPage,
isFetchingPreviousPage,
isLoading,
isError,
isFetched,
data,
};
};
export default useBooks;
import { useParams } from "react-router-dom";
import useBooks from "../hooks/useBooks";
function BookSearchResultPage() {
const { keyword } = useParams();
const { isLoading, data } = useBooks(keyword);
console.log(data?.pages.map((v) => v.item)[0]);
const finalData = data?.pages.map((v) => v.item)[0];
return (
<main>
{isLoading && <div>loading...</div>}
{data &&
finalData?.length > 0 &&
data?.pages.map((v) =>
v.item.map((val, i) => <div key={i}>{val.title}</div>)
)}
{finalData?.length == 0 && <div>no data</div>}
</main>
);
}
export default BookSearchResultPage;
useInfiniteQuery()는 기본적으로 useQuery() 함수와 비슷하지만 무한 스크롤을 구현하기에 최적화되어 있다.
주의할 점은 @tanstack/react-query가 v5로 오면서 쿼리 키와 쿼리 함수를 넣는 방식이 약간 달라졌다는 것. 별 건 아니지만 이걸 몰라서 잠시 헤맸던 전적이 있어서 정리한다.
// queryKey, queryFn 넣는 방법 주의
useInfiniteQuery({
queryKey,
queryFn: ({ pageParam }) => fetchPage(pageParam),
initialPageParam: 1,
getNextPageParam: (lastPage, allPages) => {
const nextPage = allPages.length + 1;
return lastPage?.data.length === 0 ? undefined : nextPage;
},
select: (data) => ({
pages: data?.pages.flatMap((page) => page.data),
pageParams: data.pageParams,
}),
})
queryKey: 리액트 쿼리는 쿼리키를 기준으로 쿼리의 캐싱을 관리한다. 그렇기 때문에 쿼리키는 고유해야 한다. 쿼리키는 배열 형태로 작성하며 문자열, 변수가 들어갈 수 있다. 예를 들어, 문자열 'books'를 넣어 책과 관련된 데이터임을 명시할 수 있다. 변수를 넣는다면 useEffect의 디펜던시 배열이라고 생각하면 될 듯하다. useEffect의 디펜던시 배열에 들어있는 변수의 상태가 바뀌면, useEffect 내부의 첫번째 함수가 실행되는 것처럼 말이다.queryFn이 어떤 변수에 따라 바뀐다면, 쿼리 키에도 집어 넣어서 그 변수가 변화할 때마다queryFn이 트리거되도록 하는 것이다.
If your query function depends on a variable, include it in your query key.
queryFn: 실행할 함수initialPageParam: 처음에 보여줄 페이지getNextPageParam: 말 그대로 다음 페이지를 반환한다. 다음 페이지가 없다면 undefined를 반환한다.select: 받을 데이터를 select하여 어떻게 받을지 조작할 수 있다. 이 조작을 외부에서 하지 말고select를 이용하면 간단하게 받을 수 있다. 공식 문서에서는 받은 데이터를 reverse() 하는 예시를 들어놨다. 데이터가 엄청 중첩된 상태로 전달되기 때문에 굉장히 헷갈리는 부분이다.
마주쳤던 문제

// 필요없는 💩 코드
const [books, setBooks] = useState();
// 필요없는 💩 코드 2
useEffect(() => {
setBooks(data?.pages.map((v) => v.item))
}, [keyword]);
-> 앱을 실행하면 데이터가 받아와지지만 setBooks()를 해줘도 books는 undefined가 뜬다. 그러다가 vscode에서 cmd+s 를 눌러서 저장을 하면 갑자기 또 제대로 뜬다.
이 문제를 한국어로 치면 나오지도 않을 것 같고(실제로 안 쳐봄), 영어로 쳤는데 나와 비슷한 사례를 겪은 사람이 많지는 않은 듯했다.
그러나 다행히 아예 없진 않았다! 그리고 이런 어이 없는 발상을 많은 사람들이 하지 않아서인지 대답을 해주는 사람들도 대부분 제대로 이해를 못하고 쿼리키, 쿼리 함수에 변수를 제대로 넣으라고만 하고. 리턴을 제대로 했냐고만 하고. 아무래도 이 상황 자체가 어이없는 상황이어서 그런듯.
리액트 쿼리를 제대로 이해했다면 없었을 문제다 정말.
내가 했던 대형 삽질은 받은 데이터를 그냥 바로 사용하지 않고 또 useState로 관리하며, keyword가 변경될 때마다 useEffect가 실행되도록 한 것이다. 키워드를 useEffect의 의존성 배열에 넣어놨기 때문에 키워드가 바뀌면(사용자가 검색을 하면) useEffect 내부의 setBooks가 실행되어 books 데이터가 업데이트 된다.
너무 당연하게 이렇게 짰는데 사실 리액트 쿼리를 이용한다면 이렇게 할 필요가 전.혀. 없다. 리액트 쿼리 자체가, 키워드가 변화하면 새롭게 데이터를 받아오는, 바로 그 상태관리를 해주는 도구이기 때문이다. 물론 쿼리 키와 쿼리 함수에 키워드를 제대로 넣어놨다면 말이다. 이걸 모르고 또 useEffect 어쩌구를 해버리니 제대로 앱이 동작하지 않은 것이다.
I think you are making the mistake of copying data from react-query into local state. The idea is that react-query is the state manager, so the data returned by react-query is really all you need.
-> 리액트 쿼리에서 받은 데이터를 로컬 state로 따로 저장해서 상태관리를 하려고 한 것이 문제의 원인이다. 리액트 쿼리 자체가 상태 관리를 하기 때문에 리액트 쿼리가 리턴한 데이터가 네가 원하는 바로 그 데이터다.
What you are experiencing in the codesandbox is probably just refetchOnWindowFocus. So you focus the window and click the button, react-query will do a background update and overwrite your local state. This is a direct result of the "copy" I just mentioned.
-> 아마 첫 렌더링 시에는 undefined가 뜨고 저장을 하고 돌아와서야 데이터가 제대로 뜬 것은 아마 그냥 refetchOnWindowFocus일 것이다. 나는 바보일까..?
한 마디로 그냥 사용하면 되는 data를 따로 state로 저장해서 괜한 일을 한 것. keyword가 변할 때마다 바뀐 keyword로 refetch하는 것은 리액트 쿼리가 자동으로 하는 일이다. 그런데 그걸 내가 또 하려고 하니까 이렇게 일이 꼬였던 것 같다. 어쨌든 지금은 제대로 해결을 했다! 오히려 코드는 더 간결해진 것이다. 👏👏👏
아직 무한 스크롤은 구현을 못해서 내일 마저 하기로!
'Recap > bookshelf' 카테고리의 다른 글
| [6일차] firebase로 책 데이터 관리, 해시태그 관리 (0) | 2024.01.29 |
|---|---|
| [5일차] 무한스크롤 리팩토링 및 Firebase Firestore로 데이터 관리 (0) | 2024.01.29 |
| [4일차] 무한 스크롤 구현(React Query, Intersection Observer API) 완료 (0) | 2024.01.27 |
| [2일차] firebase로 회원가입, 로그인 구현 (0) | 2024.01.25 |
| [1일차] 🎉 bookshelf 프로젝트 시작하기 (0) | 2024.01.25 |