예전에 SwiftUI를 사용해서 무한스크롤을 구현하는 법을 작성한 포스팅 작성했었다. (벌써 2년반이나 전이다.) 이번에는 React에서 무한스크롤을 구현하는 법을 배워보자.
저 포스팅에서 사용했던 방법 중에 2번 방법 (스크롤 중에 맨 아래에 ProgressView가 나타나면 데이터를 불러오는 방식을 사용할 것이다.)
useRef
는 React에서 DOM 요소나 값을 저장할 수 있는 훅이다. 리렌더링 없이 값을 유지할 수 있다는 점이 특징이다. (useState는 값을 유지할 수 있지만 리랜더링을 유발한다.) 주로 아래와 같은 상황에서 사용된다:
import { useRef } from "react";
function Example() {
const inputRef = useRef<HTMLInputElement>(null);
const focusInput = () => {
if (inputRef.current) {
inputRef.current.focus(); // input 요소에 포커스를 설정
}
};
return (
<div>
<input ref={inputRef} type="text" />
<button onClick={focusInput}>Focus Input</button>
</div>
);
}
IntersectionObserver
는 DOM 요소가 뷰포트(Viewport) 안에서 얼마나 보이는지(교차 상태)를 관찰하는 브라우저 API이다. 이를 통해 사용자는 특정 요소가 화면에 나타났는지 여부를 알 수 있다. (SwiftUI의 onAppear라고 보면 된다.)
처음에 객체를 만들 때 전달하는 콜백은 IntersectionObserverEntry[]와 IntersectionObserver 타입의 두개의 param을 받는데 이는 각각 관찰하는 대상과 관찰하는 옵저버를 의미한다.
IntersectionObserver
는 무한 스크롤, 레이지 로딩 이미지, 애니메이션 트리거 등 다양한 곳에서 활용된다.
아래 코드는 React에서 IntersectionObserver
와 useRef
를 활용해 무한 스크롤을 구현한 예시이다. useRef
를 통해 div의 참조를 얻어 IntersectionObserver
의 옵저빙 대상으로 등록하여 화면에 나타나면 추가적인 데이터를 불러오는 함수를 실행하는 방식이다.
아래 사항에 주의해서 코드를 살펴보자!
"use client";
import { useEffect, useRef, useState } from "react";
import { getMoreWords } from "./dbClient.ts"; // 데이터를 가져오는 함수
interface WordListProps {
initialWords: string[];
}
export default function WordList({ initialWords }: WordListProps) {
const [words, setWords] = useState(initialWords);
const [isLoading, setIsLoading] = useState(false);
const [page, setPage] = useState(0);
// 마지막 페이지라면 trigger를 숨기기 위해 필요!
const [isLastPage, setIsLastPage] = useState(false);
const trigger = useRef<HTMLSpanElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
async (entries, observer) => {
const element = entries[0]; //👉 옵저빙 대상 (= trigger)
if (element.isIntersecting && trigger.current) {
observer.unobserve(trigger.current); //❗️ 중복 실행 안되게 unobserve하기
// 로딩을 세팅하고 추가적인 단어 불러오기
setIsLoading(true);
const newWords = await getMoreWords(page + 1);
// 마지막 페이지면 마지막 페이지를 체크
if (newWords.length !== 0) {
setPage((prev) => prev + 1);
setWords((prev) => [...prev, ...newWords]);
} else {
setIsLastPage(true);
}
setIsLoading(false);
}
},
{ threshold: 1.0 } //👉 100% 보였을 때 콜백을 실행한다. 0.5라면 50% 이상 보이면 실행
);
// trigger의 참조를 observer에 등록한다.
if (trigger.current) {
observer.observe(trigger.current);
}
// 클리너 함수! (이 페이지를 벗어나면 옵저버 해제!)
return () => observer.disconnect();
}, [page]); //❗️ dependency를 page로! 페이지가 +1 되면 다시 옵저버 등록한다!
return (
<div className="p-5 flex flex-col gap-5">
{words.map((word, index) => (
<div key={index} className="p-3 border rounded">
{word}
</div>
))}
{!isLastPage && <div ref={trigger} className="loader">Loading...</div>}
</div>
);
}
기존의 무한 스크롤 구현 방식은 스크롤 이벤트를 감지해 계산을 반복적으로 수행하는 방식이었다. 그러나 이는 성능 문제가 발생할 수 있다. (스크롤 할 때 마다 콜백을 실행해야 하기 때문) 반면, IntersectionObserver
는 브라우저 레벨에서 최적화된 감지 기능을 제공하므로 더 효율적이고 정확하다.