출처) Infinite Scrolling With React - Tutorial(YouTube)
책 검색 결과를 무한스크롤로 구현해보자
export const useBookSearch = (query, pageNumber) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [books, setBooks] = useState([]);
const [hasMore, setHasMore] = useState(false);
useEffect(() => {
setBooks([]);
}, [query]);
useEffect(() => {
setLoading(true);
setError(false);
const controller = new AbortController();
axios.get(
`https://openlibrary.org/search.json?q=${query}&page=${pageNumber}`,
{
signal: controller.signal,
}
)
.then(res => {
setBooks(prevBooks => {
return [
...new Set([...prevBooks, ...res.data.docs.map((b) => b.title)]),
];
});
setHasMore(res.data.length > 0);
setLoading(false);
})
.catch(error => {
if (error.name === 'AbortError') {
return;
}
setError(true);
});
return () => controller.abort();
}, [query, pageNumber]);
return { loading, error, books, hasMore };
};
useEffect(() => {
setBooks([]);
}, [query]);
// useEffect내
setLoading(true);
setError(false);
query
또는 pageNumber
가 변경될 때 "loading..." 글자를 띄우기 위해 setLoading
을 true
로 바꾼다.query
, pageNumber
가 변경될 때는 에러가 발생하는게 아니므로 false
처리한다.const controller = new AbortController();
axios.get(
`url?q=${query}&page=${pageNumber}`,
{
signal: controller.signal,
}
)
.then(res => {
setBooks(prevBooks => {
return [
...new Set([...prevBooks, ...res.data.docs.map((b) => b.title)]),
];
});
setHasMore(res.data.docs.length > 0);
setLoading(false);
})
.catch(error => {
if (error.name === 'AbortError') {
return;
}
setError(true);
});
prevBooks
: 검색 후 나온 결과 목록(스크롤 해서 데이터가 추가되기 직전 목록)...new Set([...prevBooks, ...res.data.docs.map((b) => b.title)])
...new Set
: set 중복 제거setHasMore(res.data.length > 0);
: 0보다 크면 true, 더 이상 불러올 데이터가 없는 경우는 0이므로 falsesetLoading(false);
: 데이터를 불러온 후에는 loading falseif (axios.isCancel(error)) {return;}
: 요청이 취소가 되면 returnsetError(true);
: 에러가 생기면 error trueuseEffect(()=>{
...
return { loading, error, books, hasMore };
},[])
return { loading, error, books, hasMore };
import { useBookSearch } from './useBookSearch';
function App() {
const [query, setQuery] = useState('');
const [pageNumber, setPageNumber] = useState(1);
const { books, hasMore, loading, error } = useBookSearch(query, pageNumber);
const observer = useRef();
const lastBookElementRef = useCallback(
(node) => {
if (loading) return;
if (observer.current) observer.current.disconnect();
observer.current = new IntersectionObserver((entries) => {
console.log('entries[0].isIntersecting : ', entries[0].isIntersecting);
if (entries[0].isIntersecting && hasMore) {
setPageNumber((prevPageNumber) => prevPageNumber + 1);
}
});
if (node) observer.current.observe(node);
// console.log(node);
},
[loading, hasMore]
);
console.log('observer.current : ', observer.current);
const handleSearch = (e) => {
setQuery(e.target.value);
setPageNumber(1);
};
return (
<>
<input type="text" value={query} onChange={handleSearch}></input>
{books.map((book, index) => {
if (books.length === index + 1) {
return (
<div ref={lastBookElementRef} key={book}>
{book}
</div>
);
} else {
return (
<Fragment key={book}>
<div>index : {index}</div>
<div>{book}</div>
</Fragment>
);
}
})}
{loading && '...loading'}
<div>{error && 'Error'}</div>
</>
);
}
export default App;
const [query, setQuery] = useState('');
const [pageNumber, setPageNumber] = useState(1);
query
: useBookSearch
에 props로 전달할 검색어pageNumber
: useBookSearch
에 props로 전달할 페이지번호(1부터 시작)const { books, hasMore, loading, error } = useBookSearch(query, pageNumber);
const observer = useRef();
Intersection Observer API
를 사용해서 특정 요소를 관찰할 수 있다.observer
: 관찰하려는 요소const lastBookElementRef = useCallback(
(node) => {
if (loading) return;
if (observer.current) observer.current.disconnect();
observer.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasMore) {
setPageNumber((prevPageNumber) => prevPageNumber + 1);
}
});
if (node) observer.current.observe(node);
// console.log(node);
},
[loading, hasMore]
);
lastBookElementRef
: loading과 hasMore의 값이 바뀔 때만 새로 실행된다.
node
: 책 목록 중 마지막 요소
if (loading) return;
if (observer.current) observer.current.disconnect();
마지막 요소를 어떻게 구할까?
jsx 부분에서 div에 ref={lastBookElementRef}으로 걸어놨는데, map 함수에서 if문 조건을 보면 다음과 같다.
books.length === index + 1
책 목록의 길이가 index에 1을 더한 값과 같아지면 그 때의 해당 요소가 책 목록 배열의 마지막 요소가 된다.
entries
: IntersectionObserverEntry, IntersectionObserver가 사용할 수 있는 값들의 배열이다.
entries[0].isIntersecting
: boolean
값이며, 관찰중인 요소가 root
(따로 설정하지 않았으므로 뷰포트) 영역 안에 들어왔는 지 여부이다.
isIntersecting
은 관찰 대상이 루트 요소와 교차 상태로 들어가거나(true
) 교차 상태에서 나가는지(false
) 여부를 나타내는 값(Boolean
)입니다.false
이지만, 마지막 요소가 화면 안에 들어오면 true
로 바뀌고, 로딩이 끝나면 다시 false
가 된다.if (entries[0].isIntersecting && hasMore)
isIntersection === true
이고, 로딩할 데이터가 더 있다면(hasMore === true
) 페이지 번호에 1을 더한다.if (node) observer.current.observe(node);
node
)가 잡히면 해당 요소의 관찰을 시작한다.if (loading) return;
: 로딩 중에 따로 이렇게 처리하지 않으면 불필요한 API호출이 생길 수 있기 때문에 return처리한다.
무한스크롤을 구현해야 해서 여기저기 찾아보다 찾은 유튜브 영상이다.
3년 전 영상이라 axios가 예전 버전이어서 조금 수정했다.
영상에 나온 openlibrary API는 검색어와 페이지를 url에 쿼리파라미터로 추가하면 해당하는 책 정보를 json으로 응답으로 보내준다. 무한스크롤 연습할 때 좋은 것 같다!
참고) Open Library Search API