useRef와 useCallback의 도움을 받아 Intersection Observer API로 무한 스크롤 기능을 구현해봤다.
우선 Intersection Observer API 관련 MDN 문서를 읽고 나서 나름의 정리를 해봤다.
핵심은 다음과 같다.
let observer = new IntersectionObserver(callback, options);
observer.observe(target);
intersection이 발생한 경우, 콜백함수 안에 원하는 일련의 workflow를 작성하면 된다.
options 관련해서는 MDN 문서에 잘 정리되어 있다.
책 제목을 불러오는 커스텀 훅(useBookSearch)을 사용한 예제 코드다.
한가지 주목할 점은 ref에 callback ref를 전달할 수 있다.
callback ref 함수를 통해 ref가 설정되고 해제되는 로직을 세세하게 처리할 수 있다.
추가적인 작업을 할 수 있다! 정도로 알고 있으면 충분할 것 같다(?)
참고로 아래 코드에서 console.log(node); 를 실행하면 해당하는 html 요소를 출력한다.
🎯 아래 코드의 순서는 다음과 같다.
아래 App.js 코드 중 disconnect 하는 부분(🔵)을 주의해야 한다.
이 부분을 작성하지 않고 위쪽으로 스크롤을 하면 추가적인 데이터를 불러오게 된다.
(아래쪽이 아니라 위쪽으로 스크롤을 했는데 데이터가 갱신되는 건 확실히 이상하다 😅)
관찰자에 등록된 요소들에 대해 관찰을 중지하지 않았기 때문이다.
따라서 기존에 등록된 요소들에 대한 관찰을 모두 멈춰야 한다.
// 📍 App.js
import { useState, useRef, useCallback } from "react";
import useBookSearch from "./useBookSearch";
import { ClipLoader } from "react-spinners";
function App() {
const [query, setQuery] = useState("");
const [pageNum, setPageNum] = useState(1);
const { books, isLoading, isError, hasMore } = useBookSearch(query, pageNum);
const observer = useRef();
const lastBookElementRef = useCallback(
node => {
// 🟠 로딩 중에는 아래의 로직 실행을 방지
if (isLoading) return;
// ✅ 4. 관찰자 등록
if (observer.current) observer.current.disconnect(); // 🔵
observer.current = new IntersectionObserver(entries => {
if (entries[0].isIntersecting && hasMore) {
// ✅ 5. 상태(pageNum) 업데이트 -> 커스텀 훅(useBookSearch) useEffect 실행
setPageNum(prev => prev + 1);
}
});
if (node) observer.current.observe(node);
},
[isLoading, hasMore]
);
function handleSearch(e) {
setQuery(e.target.value);
}
return (
<>
{/* ✅ 1. 상태(query) 업데이트 */}
<input type="text" value={query} onChange={handleSearch} />
{books.map((book, idx) => {
if (books.length === idx + 1) {
return (
// 🟠 마지막 데이터인 경우에 예외 처리
<div key={idx} ref={lastBookElementRef}>
{book}
</div>
);
}
return <div key={idx}>{book}</div>;
})}
<div>{isLoading && <ClipLoader />}</div>
<div>{isError && "Error"}</div>
</>
);
}
export default App;
// 📍 useBookSearch.js
import { useState, useEffect } from "react";
import axios from "axios";
export default function useBookSearch(query, pageNum) {
const [isLoading, setIsLoading] = useState(true);
const [isError, setIsError] = useState(false);
const [books, setBooks] = useState([]);
const [hasMore, setHasMore] = useState(false);
useEffect(() => {
setBooks([]);
}, [query]);
useEffect(() => {
setIsLoading(true);
setIsError(false);
let cancel;
axios({
method: "GET",
url: "http://openlibrary.org/search.json",
params: { q: query, page: pageNum },
// 🟠 axios 요청을 취소 (다른 요청을 보내는 중이라면)
cancelToken: new axios.CancelToken(c => (cancel = c)),
})
.then(res => {
// ✅ 3. 새로운 axios 요청에 대한 응답 처리
setBooks(prevBooks => [
...new Set([...prevBooks, ...res.data.docs.map(book => book.title)]),
]);
setHasMore(res.data.docs.length > 0);
setIsLoading(false);
})
.catch(err => {
// 🟠 axios 요청 취소 시 발생하는 에러 처리
if (axios.isCancel(err)) return;
setIsError(true);
});
return () => cancel();
// ✅ 2. 상태(query) 업데이트로 인해 useEffect 실행
}, [query, pageNum]);
return { isLoading, isError, books, hasMore };
}