개요
- 먼저 hook과 useInfiniteQuery를 사용하지 않고 순수하게 js와 react의 기능만을 사용해 무한스크롤을 구현한 예제를 소개할 것이다.
- 그리고 Intersection Observer로 특정 element를 구독하는 과정을 간단하게 할 수 있는 useIntersect hook을 만들어보고, 그 hook의 callback으로 useInfiniteQuery의 fetchNextPage 함수를 줘서 무한스크롤로 API 호출하는 예제를 작성해 볼 것이다.
1. 순수하게 intersect observer 이용해 무한스크롤 구현하기
hook으로 로직 추상화를 하거나 별도의 라이브러리 없이 infinite observer로 target을 구독하는 코드를 직접 구현해봤다.
function InfiniteScrollPage() {
const [images, setImages] = useState<any[]>([]);
const [pageNum, setPageNum] = useState(1);
const [isLoading, setIsLoading] = useState(false); // loading 상태를 관리할 state
const pageEnd = useRef<HTMLDivElement>(null); // target에 부착할 ref
// pageNum이 변하면 자동으로 이미지 fetch를 한다.
useEffect(() => {
fetchImages(pageNum);
}, [pageNum]);
useEffect(() => {
const callback = (entries: IntersectionObserverEntry[]) => {
if (!isLoading && entries[0].isIntersecting) {
loadMore();
}
};
const observer = new IntersectionObserver(callback, { threshold: 1 });
let target = pageEnd.current;
target && observer.observe(target);
return () => {
target && observer.unobserve(target);
};
}, [isLoading]);
const loadMore = () => {
setPageNum((prev) => prev + 1);
};
loadMore라는 함수는 page += 1을 해주는 함수이다.
useEffect 부분을 살펴보면, observer로 target을 구독하도록 하고 있는데, 이 observer는 교차가 발생할 때마다 loadMore()를 실행해 page 수를 1 증가시킨다.
이렇게 pageNum이 증가하면, pageNum을 의존배열로 가진 또 다른 useEffect에 의해 fetchImages가 실행되는 구조이다.
로딩이 되어 isLoading이 true가 되면 deps 배열에 있는 isLoading이 변했기 때문에, useEffect의 return 부분이 실행되어 observer가 clean-up 된다. 그리고 로딩이 끝나 다시 isLoading이 false가 되면 새 observer가 생성되게 된다.
이렇게 isLoading과 clean-up을 이용하여 구독이 중복으로 일어나지 않게 했다.
async function fetchImages(page: number) {
try {
setIsLoading(true);
const res = await axios.get(
`https://api.unsplash.com/photos?client_id=${process.env.REACT_APP_UNSPLASH_API_KEY}&page=${page}&per_page=15`
);
// await sleep(5000); // 5초의 시간동안 지연시켜서 loading하는 동안 위아래 스크롤로
// callback이 중복실행되지 않는지 검사하는 용도
setImages([...images, ...res.data]);
} catch (e) {
alert('err: ' + e);
} finally {
setIsLoading(false);
}
}
const sleep = (delay: number) => {
return new Promise((resolve) => setTimeout(resolve, delay));
};
return (
<Wrapper>
{images && <CardList images={images} />}
<Loading pageEnd={pageEnd} />
</Wrapper>
);
}
const Wrapper = styled.div`
padding: 0 24px 0 24px;
margin-right: auto;
margin-left: auto;
`;
export default InfiniteScrollPage;
2. Intersection Observer hook과 useInfinite-Query 이용해 무한스크롤 구현
Intersection Observer hook - useIntersect 만들기
이 hook은 ref를 생성한 다음, useEffect 안에서 observer 객체를 생성해 ref를 구독하게 만들고,
최종적으로 그 ref를 반환하고 있다.
이렇게 observer가 구독하고 있는 ref를 target 요소에 붙이는 방식으로 특정 엘리멘트 구독을 편리하게 처리할 수 있다.
/* src/hooks/useIntersect.ts */
import { useEffect, useRef, useCallback } from 'react';
type IntersectHandler = (
entry: IntersectionObserverEntry,
observer: IntersectionObserver
) => void;
function useIntersect(
onIntersect: IntersectHandler,
options?: IntersectionObserverInit
) {
const ref = useRef<HTMLDivElement>(null); // target 요소를 저장할 ref
const callback = useCallback(
(entries: IntersectionObserverEntry[], observer: IntersectionObserver) => {
// entries 배열인 이유는 한 observer가 여러 대상을 관찰할 수 있기 때문
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(); // unmount되면 구독 해제
}, [ref, options, callback]);
return ref;
}
export default useIntersect;
useIntersect의 인자로 받은 onIntersect 함수는 교차될 때 실행할 행동을 담은 콜백 함수이다.
교차가 발생할 때 observer의 callback 함수는 교차 상태를 묘사하는 entry라는 요소를 인자로 받게 되는데, 그를 통해 intersecting 여부를 검사해 onIntersect를 수행한다.
꼭 useEffect의 return 부분에 clean-up 하는 코드를 작성해서 중복 구독으로 인해 매 관찰마다 구독이 2, 4, 8번으로 늘어나는 일이 발생하지 않도록 한다.
useFetchImages hook - useInfiniteQuery로 이미지 fetch 하는 hook
이 hook은 이미지 fetch 로직과 useInfiniteQuery를 결합하여 이미지 무한 fetch를 쉽게 할 수 있게 하는 hook이다.
useInfiniteQuery의 인자로 이미지 fetch로직을 담고 있는 getPageImageData 함수를 넣어준다.
return 부분에서 useInfiniteQuery 함수를 실행해 결과적으로 { data, hasNextPage, isFetching, fetchNextPage }를 반환해준다.
import axios from 'axios';
import { useInfiniteQuery } from '@tanstack/react-query';
function useFetchImages(startPage: number) {
// image fetch 로직
const getPageImageData = async ({ pageParam = startPage }) => {
const res = await axios.get(`https://api.unsplash.com/photos`, {
params: {
client_id: process.env.REACT_APP_UNSPLASH_API_KEY,
page: pageParam,
per_page: 15,
},
});
return {
current_page: pageParam,
data: res.data,
isLast: false,
};
// 이 반환 데이터 형식은 unsplash 데이터에 page와 isLast 페이지 여부가 없길래
// 내가 임의로 가공한 것이다.
// isLast 여부를 알려주는 API를 사용하고 있다면 내가 임의로 넣은 false를
// API 반환값으로 대체하면 된다.
};
return useInfiniteQuery(['images'], getPageImageData, {
getNextPageParam: (lastPage, totalPages) => {
return lastPage.isLast ? undefined : lastPage.current_page + 1;
},
staleTime: 60000 * 20, // 1분 * n
});
}
export default useFetchImages;
useInfiniteQuery 부분을 설명하자면,
- 배열 ['images']에 담긴 images는 useInfiniteQuery로 불러온 데이터에 붙여줄 key 네임이고,
- getNextPageParam은 lastPage와 totalPage를 인자로 받기 때문에, lastPage가 아니라면 +1 한 페이지를 가져오는 등의 로직을 작성할 수 있다.
- staleTime은 이 데이터가 얼마동안 신선하다고 할 지 시간 설정을 하는 부분이다. 여태까지 불러온 데이터들이 배열에 쫙 들어있다면 그 데이터를 불러오기 위해 여태 사용한 query들을 staleTime이 지났을 때 다시 한번 실행한다.
(따라서 경우에 따라 API 요청이 어마무시하게 많이 되는 모습을 볼 수 있는데, 그게 싫다면 새 데이터로 덜 자주 교체하도록 시간을 잡아도 된다)
useIntersect와 useInfiniteQuery를 적용한 최종 모습
앞에서 만든 useFetchImages hook은 useInfiniteQuery를 실행한 결과를 반환하고 있다.
useInfiniteQuery가 반환하는 객체와 함수 중에는 fetchNextPage라는 것이 있는데,
이 함수를 실행하면 페이지 parameter를 1 증가시킨다.
그리고 그 때 실행할 함수로 useInfiniteQuery에 넣은 getPageImageData 함수에 이 새로운 페이지 파라미터를 넣어 다음 데이터를 불러오는 구조인 것이다.
import React, { useMemo } from 'react';
import CardList from '../InfiniteScroll/components/CardList';
import styled from 'styled-components';
import Loading from '../InfiniteScroll/components/Loading';
import useFetchImages from '../InfiniteScroll/hooks/useFetchImages';
import useIntersect from '../InfiniteScroll/hooks/useIntersect';
function InfiniteScrollPage() {
const { data, hasNextPage, isFetching, fetchNextPage } = useFetchImages(1);
const images = useMemo(() => {
return data ? data.pages.flatMap(({ data }) => data) : [];
}, [data]);
const pageEnd = useIntersect(async (entry, observer) => {
observer.unobserve(entry.target);
await sleep(700); // api 데이터를 너무 빨리 전송받으면
// intersecting을 체감할 수 없기 때문에 의도적으로 sleep을 걸어줬다.
if (hasNextPage && !isFetching) {
fetchNextPage();
}
});
// 로딩 시간 지연 테스트용
const sleep = (delay: number) => {
return new Promise((resolve) => setTimeout(resolve, delay));
};
return (
<Wrapper>
{images && <CardList images={images} />}
{hasNextPage && <Loading pageEnd={pageEnd} />}
</Wrapper>
);
}
const Wrapper = styled.div`
padding: 0 24px 0 24px;
margin-right: auto;
margin-left: auto;
`;
export default InfiniteScrollPage;
+) 기타 - 마크업 및 unsplash 키 관리
unsplash 개발자 센터에 가서 key 발급한 것을 프로젝트 최상단에 .env 파일을 생성해 넣어줬다.
REACT_APP_UNSPLASH_API_KEY=발급받은 key
이 프로젝트에 사용된 컴포넌트 마크업 부분
CardList 컴포넌트
import styled from 'styled-components';
import Card from './Card';
interface CardListProps {
images: any[];
}
function CardList({ images }: CardListProps) {
return (
<CardListBlock>
{images.map((image, i) => {
const imgUrl = image.urls.small;
return <Card key={i} imgUrl={imgUrl} />;
})}
</CardListBlock>
);
}
const CardListBlock = styled.div`
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin: 0 auto;
max-width: 900px;
`;
export default CardList;
Card 컴포넌트
import styled from 'styled-components';
interface CardProps {
imgUrl: string;
}
function Card({ imgUrl }: CardProps) {
return (
<CardBlock>
<CardImage src={imgUrl} />
</CardBlock>
);
}
const CardBlock = styled.div``;
const CardImage = styled.img`
width: 300px;
height: 400px;
`;
export default Card;
PageEnd 컴포넌트 (target 역할. 이 부분에 ref를 부착해 intersect를 관찰한다)
interface LoadingProps {
pageEnd: RefObject<HTMLDivElement>;
}
function Loading({ pageEnd }: LoadingProps) {
return (
<div
ref={pageEnd}
style={{ fontSize: '2rem', marginTop: '0px', textAlign: 'center' }}
>
...Loading
</div>
);
}
export default Loading;
'UI 구현 연구일지' 카테고리의 다른 글
[React] Debounce(디바운스) 적용한 검색어 자동완성 구현기 (2) - 검색어 키보드로 선택하기 (0) | 2023.06.14 |
---|---|
[React] Debounce(디바운스) 적용한 검색어 자동완성 구현기 (1) (0) | 2023.06.14 |
[React 컴포넌트] 댓글 기능 구현하기 (0) | 2022.07.12 |
[React 컴포넌트] Like, Dislike (좋아요, 싫어요) 기능 만드는 법 (0) | 2022.07.11 |
[React 컴포넌트] Favorite(찜, 좋아요) 버튼 만드는 법 (0) | 2022.07.11 |