
이제 스크롤 처리를 하기 위해서 IntersectionObserver를 사용해야한다.
IntersectionObserver 인터페이스는 대상 요소와 상위 요소, 또는 대상 요소와 최상위 문서의 뷰포트가 서로 교차하는 영역이 달라지는 경우 이를 비동기적으로 감지할 수 있는 수단을 제공
브라우저의 뷰포트와 대상 요소 간의 교차 여부를 감지하며, 특히 요소가 화면에 나타나거나 사라지는 것을 감지할 때 유용함
관련 공식문서
https://developer.mozilla.org/ko/docs/Web/API/Intersection_Observer_API
https://developer.mozilla.org/ko/docs/Web/API/IntersectionObserver/IntersectionObserver
var intersectionObserver = new IntersectionObserver(function (entries) {
// intersectionRatio가 0이라는 것은 대상을 볼 수 없다는 것이므로
// 아무것도 하지 않음
if (entries[0].intersectionRatio <= 0) return;
loadItems(10);
console.log("새 항목 불러옴");
});
// 주시 시작
intersectionObserver.observe(document.querySelector(".scrollerFooter"));
위 예제코드와 같이 해당 요소가 화면에 나타났을 때 원하는 로직(loadItems)을 수행할 수 있다.
하단에 도달하면
// INFINITE SCROLL OBSERVER
const observerRef = useRef(null);
const observerCallback: IntersectionObserverCallback = useCallback(
(entries) => {
const lastPage = searchResults?.pages[searchResults.pages.length - 1];
entries.forEach((entry) => {
if (
// entry is 'IntersectionObserverEntry'
entry.isIntersecting &&
lastPage &&
lastPage.tracks.items.length > 0
) {
fetchNextPage();
}
});
},
[fetchNextPage, searchResults?.pages]
);
useEffect(() => {
const currentRef = observerRef.current;
if (!currentRef) return;
const observer: IntersectionObserver = new IntersectionObserver(
observerCallback,
{
threshold: 0,
}
);
// 대상 요소 관찰
observer.observe(currentRef);
// 컴포넌트 해제시 관찰 중지
return () => observer && observer.unobserve(currentRef);
}, [observerCallback, observerRef]);
관찰 대상이 현재 루트 안에 포함되어 있는지의 여부
threshold 옵션은 보이는 부분의 비율을 나타내며 기본값은 0이다.
예를 들어, 0.5일 경우 대상 요소의 50% 이상이 화면에 보일 때 콜백이 호출됨
또한, [0, 0.25, 0.5, 0.75, 1]와 같이 배열형태로도 설정이 가능해 대상 요소가 threshold%만큼 보일 때마다 각각 호출이 일어날 수도 있다
+이와 같은 option에 root(기본값은 null, 브라우저의 뷰포트)와 rootMargin도 있으나 당장 필요한 기능은 아니라 생략
https://heropy.blog/2019/10/27/intersection-observer/
코드 길이도 길어졌고, 분명 다시 쓰일일이 있을 듯하여 따로 분리했다
const useIntersectionObserver = ({
target,
threshold = 0,
onObserverCallback,
}: IntersectionObserverType) => {
useEffect(() => {
const currentRef = target?.current;
if (!currentRef) return;
const observer: IntersectionObserver = new IntersectionObserver(
onObserverCallback,
{
threshold: threshold,
}
);
observer.observe(currentRef);
return () => observer && observer.unobserve(currentRef);
}, [onObserverCallback, target, threshold]);
};
export default useIntersectionObserver;
짧은 시간내 연속적으로 발생한 이벤트를 하나로 처리하는 방식, 과도한 이벤트의 호출을 방지함
'가나'를 검색할때, input Value를 console에 찍어보면 'ㄱ', '가', '간', '가나'와 같이 입력하는 족족 API요청이 들어간다.
이렇게 필요 이상의 요청을 만들고 싶지 않을때 사용한다.
const [debouncedVal, setDebouncedVal] = useState("");
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedVal(searchVal.trim());
}, 500);
return () => {
clearTimeout(timer);
};
}, [searchVal]);
간단하게 debounceVal를 따로 state를 분리하고, setTimeout함수로 0.5초 이후 검색하도록 하면 된다.
이전에 설정된 타이머가 남아있지 않도록 useEffect훅이 재실행되기 전에 이전에 설정된 타이머를 취소해줘야한다.
import { useEffect, useState } from "react";
const useDebounce = (val: string, delay: number) => {
const [debouncedVal, setDebouncedVal] = useState("");
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedVal(val.trim());
}, delay);
return () => {
clearTimeout(timer);
};
}, [val, delay]);
return debouncedVal;
};
export default useDebounce
// page.tsx
const debouncedSearchVal = useDebounce(searchVal, 500);
커스텀훅으로 분리하면 보다 깔끔하게 사용가능하다
export default function SearchPage() {
const [searchVal, setSearchVal] = useState("");
const ITEMS_PER_PAGE = 10;
const observerRef = useRef(null);
const handleSearchVal = (val: string) => {
setSearchVal(val);
};
// Debounce
const debouncedSearchVal = useDebounce(searchVal, 500);
// Data(Infinite)
const handleSearchForInfinite = async (
q: string,
limit: number,
offset: number
) => {
const response = await getSearchResultAPI({
q: q,
type: ["track"],
market: "KR",
limit: limit,
offset: offset,
});
return response;
};
const {
data: searchResults,
isLoading,
isError,
error,
isFetching,
fetchNextPage,
} = useInfiniteQuery({
queryKey: ["searchInfinite", { searchVal: debouncedSearchVal }],
initialPageParam: 0,
queryFn: ({ pageParam }) =>
handleSearchForInfinite(debouncedSearchVal, ITEMS_PER_PAGE, pageParam),
getNextPageParam: (lastPage, allPages, lastPageParam) => {
let nextOffset = lastPageParam + ITEMS_PER_PAGE;
return nextOffset;
},
enabled: !!debouncedSearchVal.trim(),
});
// Infinite Scroll
const observerCallback: IntersectionObserverCallback = useCallback(
(entries) => {
const lastPage = searchResults?.pages[searchResults.pages.length - 1];
entries.forEach((entry) => {
if (
entry.isIntersecting &&
lastPage &&
lastPage.tracks.items.length > 0
) {
fetchNextPage();
}
});
},
[fetchNextPage, searchResults?.pages]
);
useIntersectionObserver({
target: observerRef,
onObserverCallback: observerCallback,
});
다른 것 보다 표면적으로 useEffect가 작성되지 않은 컴포넌트라니 새롭다.
{/* 무한 스크롤 LOADING */}
<div ref={observerRef}>{isFetching && <Spinner />}</div>
useInfiniteQuery에서 제공하는 isFetching으로 무한스크롤에서 발생하는 로딩스피너의 노출을 관리할 수 있다.