// page/infinite-scroll.tsx
const InfiniteScrollPage: NextPage = () => {
const [products, setProducts] = useState<Product[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isScrollable, setIsScrollable] = useState(false);
const currentPage = useRef(1);
const fetchProducts = async (page: number) => {
setIsLoading(true);
try {
const { data } = await getProducts(page, PRODUCTS_LENGTH);
const { products } = data.data;
// 데이터 누적
setProducts((prev) => [...prev, ...products]);
} catch (error: any) {
alert(error.message);
}
setIsLoading(false);
};
// useIntersect훅에 타겟 감지 시 실행해야할 콜백함수 전달
const ref = useIntersect((entry, observer) => {
// 데이터 요청 실패 시 연속 호출을 막기 위해 타겟 해제
observer.unobserve(entry.target);
// 불러올 데이터가 더 이상 없는지 체크
const isLastPage = allProducts.length === products.length;
if (!isLastPage && !isLoading) {
fetchProducts(currentPage.current);
// 다음 데이터를 부르기 위해 page숫자 증가
currentPage.current++;
}
});
return (
<>
<Container>
<ProductList products={products} onClick={handleClickProduct} />
// 리스트 밑에 빈 태그를 두어 타겟으로 지정
<div ref={ref}>{isLoading ? '로딩 중입니다' : ''}</div>
</Container>
</>
);
};
// useIntersect.tsx
const useIntersect = (onIntersect: IntersectHandler, options?: IntersectionObserverInit) => {
// 타겟을 담을 ref
const ref = useRef<HTMLDivElement>(null);
// onIntersect가 변경될 때마다(새로 만들어질 때마다) 생성된다.
const callback = useCallback(
(entries: IntersectionObserverEntry[], observer: IntersectionObserver) => {
entries.forEach((entry) => {
if (entry.isIntersecting) onIntersect(entry, observer);
});
},
[onIntersect]
);
useEffect(() => {
if (!ref.current) return;
const observer = new IntersectionObserver(callback, options);
observer.observe(ref.current);
return () => observer.disconnect();
}, [options, callback]); // callback이 변경될 때마다 실행된다.
return ref;
};
export default useIntersect;
타겟 감지 시에 실행될 콜백에 상태가 쓰여 리렌더링될 때마다 observer인스턴스를 새로 만드는 방법을 사용했지만 효율적인 방법은 아닌 것 같다. 다른 방법도 생각해봐야겠다.
nextjs router에 내장된 beforePopState와 sessionStorage를 활용합니다.
beforePopState는 뒤로가기를 이용한 페이지 이동 직전에 실행할 로직을 정의할 수 있습니다.
상품페이지에서 이전페이지(무한스크롤 페이지)로 이동하기 직전에 세션 스토리지에 스크롤을 되돌린다는 정보를 저장해줍니다.
// pages/products/[id].tsx
useEffect(() => {
router.beforePopState(() => {
sessionStorage.setItem('InfiniteScrollPage', 'restore');
return true;
});
}, []);
상품 클릭 시 현재의 페이지의 정보들을 세션 스토리지에 저장합니다.
스크롤 위치 뿐만 아니라 현재 불려진 데이터들과 다음 데이터를 부르기 위한 현재 페이지 숫자도 되돌려야하기 때문에 전부 저장해줍니다.
// page/infinite-scroll.tsx
const handleClickProduct = () => {
// 상품 클릭 시 현재의 데이터와 스크롤 위치를 저장
sessionStorage.setItem('PRODUCTS', JSON.stringify(products));
sessionStorage.setItem('SCROLL_HEIGHT', `${window.scrollY}`);
sessionStorage.setItem('CURRENT_PAGE', `${currentPage.current}`);
};
무한 스크롤 페이지에 들어왔을 때 이전 페이지가 상품 페이지일 경우 세션 스토리지에 저장된 데이터들로 상태 값들을 초기화합니다.
// page/infinite-scroll.tsx
const setStoredData = () => {
const savedProducts = sessionStorage.getItem('PRODUCTS');
const savedCurrentPage = sessionStorage.getItem('CURRENT_PAGE');
if (!savedProducts || !savedCurrentPage) return;
setProducts(JSON.parse(savedProducts));
currentPage.current = Number(savedCurrentPage);
};
// 이전 페이지가 상품 페이지일 경우 세션 스토리지에 저장된 데이터로 초기화
useEffect(() => {
if (!checkShouldRestore()) return;
sessionStorage.removeItem('InfiniteScrollPage');
setStoredData();
setIsScrollable(true);
}, []);
화면이 전부 렌더링되면 저장된 스크롤 위치로 이동합니다.
// page/infinite-scroll.tsx
useEffect(() => {
// 리스트가 렌더링 되면(isScrollable === true) 저장된 스크롤 위치로 이동
const savedScroll = sessionStorage.getItem('SCROLL_HEIGHT');
if (!isScrollable || !savedScroll) return;
window.scrollTo(0, Number(savedScroll));
}, [isScrollable]);
react-router-dom을 이용하면 스크롤 상태 유지를 하기 용이합니다.
router에서 넘어온 history객체 내에 action을 감지해서 pop을 식별 해 처리할 수 있기 때문입니다.
하지만 next는 자체 라우팅 시스템을 사용함에 따라 react-router-dom을 사용한 기존 방식을 사용할 수 없습니다.
대신 next Router객체에는 라우팅 도중 발생하는 이벤트에 대해 리스너를 등록할 수 있는 메소드들이 있습니다.
beforePopState() : 이전 페이지 이동 전에 무언가를 해야할 때 사용
routeChangeStart() : 경로가 변경되기 시작할 때 발생
routeChangeComplete() : 경로가 완전히 변경되면 발생
...