React Infinite scroll 구현하기

React로 Infinite scroll을 구현하면서 정리한 글이다.

Infinite scroll?

Infinite scroll은 한 번에 모든 컨텐츠를 렌더링 하지 않고 페이지 내용을 아래로 스크롤하면 새로운 컨텐츠를 덧붙여서 렌더링 하는 방식이다. 로딩해야할 컨텐츠의 양이 많다면 퍼포먼스 측면에서 infinite scroll을 고려해 볼 수 있다. 페이스북 혹은 트위터와 같은 사이트를 보면 이와 같은 UI를 적극적으로 사용하고 있다.

구현 방법

Infinite Scroll을 구현하는 방법에는 크게 두 가지가 있다.

  • onScroll event
  • Intersection Observer API

onScroll event

onScroll event를 이용한 방법은 가장 먼저 생각해 볼 수 있는 방법이다. 사용자가 scroll을 할 때 이벤트가 발생하고 현재 scroll 위치가 페이지에 끝에 닿았는지 판단한다. 페이지 끝에 도달했다면 새로운 컨텐츠를 로딩하기 위한 요청을 하고 컨텐츠를 덧붙이는 방식이다. 하지만 scroll 이벤트는 굉장히 빈번하게 발생하기 때문에 성능 최적화를 위해서 throttle과 같은 처리가 필요하다.

Intersection Observer API

Intersection Observer는 MDN 에서 아래와 같이 설명한다.

교차 영역 관찰자 API는 조상 엘리먼트 또는 최상위 도큐먼트 뷰포트와 대상 엘리먼트의 교차 영역에서 발생한 변경 사항을 비동기적으로 감시하는 방법을 제공한다.

위 정의만 읽어보면 말이 어려울 수 있는데 단순하게 DOM 엘리먼트 간에 영역이 겹쳐지는걸 감시한다고 볼 수 있다.

Intersection Observer API를 사용하면 scroll, resize와 같은 비싼 비용의 이벤트를 좀 더 쉽고 좋은 퍼포먼스로 사용할 수 있다. Lazy-load, infinite scroll 과 같은 것들을 구현할때 유용하게 사용할 수 있다. scroll 이벤트에 비해 단점이라면 아직 모든 브라우저에서 지원하지 않는다는 것이다.

이 글에선 IntersectionObserver 에 대한 자세한 설명은 하지 않는다. 더 필요한 정보는 아래 링크를 참고하도록 하자.

https://velog.io/@doondoony/IntersectionObserver

사실 브라우저 지원과 특수한 상황을 제외하곤 Intersection Observer API가 구현하기 편리하기 때문에 이 방법을 통해서 Infinite scroll을 구현하였다.

React를 통한 구현

구현하려고 했던 것은 Unsplash API를 통해서 이미지를 검색하고 결과를 Infinite scroll 을 통해 보여주는 것이였다.

Component 구조

가장 먼저 했던 작업은 Component 구조를 잡는 것 이였다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const UnsplashContainer = () => {
//...
return (
<div>
<SearchForm
onSearch={searchImage}
/>
<ScrollContainer
height={400}
vertical
ref={rootRef}
>
<ThumbnailList
thumbnails={images}
/>
<Loading show={loading}/>
<div ref={targetRef} />
</ScrollContainer>
</div>
)
};

컴포넌트 구조는 scroll 가능한 div를 셋팅해두고 그 아래에 컨텐츠 목록, 로딩 컴포넌트 그리고 마지막으로 IntersectionObserver 를 활용해서 페이지 끝을 감지하기 위해 빈 div를 추가하였다.

React State

이제 Component에서 필요한 변수, 상태, ref를 추가해야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// instance variable
const currentPage = useRef(1);
const totalPage = useRef(0);

// request state
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);

// contents list
const [images, setImages] = useState([]);

// ref
const rootRef = useRef(null);
const targetRef = useRef(null);

currentPage와 totalPage는 state로 관리할 필요는 없었기 때문에 Ref로 추가하였다.
(React Hooks에서 useRef는 이전값을 저장하거나 class component의 멤버 변수와 같이 사용할 수 있다.)
rootRef와 targetRef는 IntersectionObserver에서 사용할 실제 DOM 노드들이다.

Data Fetching

Data Fetching을 위한 함수를 몇가지 정의한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
const loadImage = useCallback(async ({ query, page }) => {
try {
setLoading(true);
const data = await UnsplashAPI.searchPhotos({ query, page, per_page: PER_PAGE});
totalPage.current = data.total_pages;
return data;
} catch(e) {
setError(e);
} finally {
setLoading(false);
}
}, []);

const searchImage = useCallback(async (query) => {
if(!query) {
await loadRandomImage();
return;
}
currentQuery.current = query;
currentPage.current = 1;
const data = await loadImage({ query, page: 1, per_page: PER_PAGE });
setImages(data.results);
}, [loadImage, loadRandomImage]);

const loadMoreImage = useCallback(async () => {
if(images.length > 0) {
currentPage.current++;
const data = await loadImage({
query: currentQuery.current,
page: currentPage.current
});
setImages([...images, ...data.results])
}
},[images, loadImage]);

loadImage는 Unsplash API를 통해서 Data를 가져오고 loading과 error 상태를 제어한다. searchImage는 맨 처음 데이터 요청에만 사용한다. 폼에서 검색을 실행하면 이 함수가 호출된다. loadMoreImage 함수는 스크롤이 끝에 닿았을때 호출된다. 여기선 페이지값을 증가시키고 컨텐츠 뒤쪽으로 새로운 컨텐츠를 붙여주면 된다.

(useAsyncFn hook을 사용하면 loading, error를 좀 더 깔끔하게 관리할 수 있다. react-use 패키지에서 사용하거나 혹은 구현해도 무관하다.)

IntersectionObserver hook

IntersectionObserver를 설정해준다. 이 부분은 검색해보면 라이브러리도 많고 일반적인 예제들도 많이 있다. 상황에 맞춰서 사용하면 될 것 같다. 나는 다른 코드를 참고해서 custom hook을 추가했다.

useIntersectionObserver.jsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { useEffect } from "react";

export default ({ root, target, onIntersect, threshold = 1.0, rootMargin = "0px" }) => {
useEffect(
() => {

if (!root) {
return;
}

const observer = new IntersectionObserver(onIntersect, {
root,
rootMargin,
threshold,
});

if (!target) {
return;
}

observer.observe(target);

return () => {
observer.unobserve(target);
};
}, [target, root, rootMargin, onIntersect, threshold]
);
};

root, threshold, rootMargin은 IntersectionObserver의 옵션들이고 target은 교차에 대해서 감시할 element 이다. onIntersect는 IntersectionObserver의 callback이라고 생각하면 된다.

위 컴포넌트 구조에서 root는 ScrollContainer로 target은 맨끝에 빈 div로 설정하였다. onIntersect 에서 몇가지 조건에 맞춰서 loadMoreImage를 호출해주면 infinite scroll이 완성된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
useIntersectionObserver({
root: rootRef.current,
target: targetRef.current,
onIntersect: ([{isIntersecting}]) => {
if(
isIntersecting &&
!loading &&
currentPage.current < totalPage.current
) {
loadMoreImage();
}
}
});

나는 위와 같은 조건을 추가해서 구현했다. 로딩중이거나 마지막 페이지가 아닐때 페이지끝에 닿으면 추가로 로딩하는 로직이다.

구현 화면

이 프로젝트는 블로그 포스트 배너를 생성해주는 banner-maker 라는 프로젝트이다.
Unsplash Image를 배경으로 활용할 수 있도록 기능을 추가해서 기여해 보았다.

마무리

scroll event를 활용해서 구현하는 것 보다 IntersectionObserver를 이용하면 좀 더 손쉽게 infinite scroll을 구현할 수 있었다. IntersectionObserver의 브라우저 지원현황이 걸린다면 polyfill을 사용할 수 있지만 완벽하진 않은 것 같다.

Ref