Intersection Observer API를 사용한 무한스크롤구현

Kim Jason·2023년 4월 4일
0
post-thumbnail

useRef와 useCallback의 도움을 받아 Intersection Observer API로 무한 스크롤 기능을 구현해봤다.

🔖 정리

우선 Intersection Observer API 관련 MDN 문서를 읽고 나서 나름의 정리를 해봤다.
핵심은 다음과 같다.

let observer = new IntersectionObserver(callback, options);
observer.observe(target);
  1. 몇가지 옵션을 갖는 Observer 객체를 만든다.
  2. 생성한 Observer 객체에게 target 요소를 observe 하도록 한다.
  3. 관찰 중인 요소에 대한 intersection이 발생한 경우 Observer 객체를 생성할 때 옵션과 함께 인자로 넣었던 콜백함수가 실행된다.

intersection이 발생한 경우, 콜백함수 안에 원하는 일련의 workflow를 작성하면 된다.
options 관련해서는 MDN 문서에 잘 정리되어 있다.

💻 코드

책 제목을 불러오는 커스텀 훅(useBookSearch)을 사용한 예제 코드다.
한가지 주목할 점은 ref에 callback ref를 전달할 수 있다.
callback ref 함수를 통해 ref가 설정되고 해제되는 로직을 세세하게 처리할 수 있다.
추가적인 작업을 할 수 있다! 정도로 알고 있으면 충분할 것 같다(?)
참고로 아래 코드에서 console.log(node); 를 실행하면 해당하는 html 요소를 출력한다.

🎯 아래 코드의 순서는 다음과 같다.

  1. (App.js) input 태그에 값을 입력하면 상태(query)가 업데이트 된다.
  2. (useBookSearch.js) 커스텀 훅의 useEffect가 실행된다. > axios 요청 발생
  3. (useBookSearch.js) axios 요청에 대한 응답을 처리한다.
  4. (App.js) isLoading이 false 값을 갖게 되면서 observer(관찰자)를 등록한다. 이때 책 제목 데이터도 동시에 갱신된다.
  5. (App.js) 뷰포트 내에 관찰하고 있는 요소(node)가 들어오게 되면 pageNum 상태를 업데이트 한다. > (useBookSearch.js) useEffect 실행
  6. 위와 동일한 로직이 반복된다.

아래 App.js 코드 중 disconnect 하는 부분(🔵)을 주의해야 한다.
이 부분을 작성하지 않고 위쪽으로 스크롤을 하면 추가적인 데이터를 불러오게 된다.
(아래쪽이 아니라 위쪽으로 스크롤을 했는데 데이터가 갱신되는 건 확실히 이상하다 😅)
관찰자에 등록된 요소들에 대해 관찰을 중지하지 않았기 때문이다.
따라서 기존에 등록된 요소들에 대한 관찰을 모두 멈춰야 한다.

// 📍 App.js

import { useState, useRef, useCallback } from "react";
import useBookSearch from "./useBookSearch";
import { ClipLoader } from "react-spinners";

function App() {
  const [query, setQuery] = useState("");
  const [pageNum, setPageNum] = useState(1);
  const { books, isLoading, isError, hasMore } = useBookSearch(query, pageNum);
  const observer = useRef();

  const lastBookElementRef = useCallback(
    node => {
      // 🟠 로딩 중에는 아래의 로직 실행을 방지
      if (isLoading) return;
      
      // ✅ 4. 관찰자 등록
      if (observer.current) observer.current.disconnect(); // 🔵
      observer.current = new IntersectionObserver(entries => {
        if (entries[0].isIntersecting && hasMore) {
          // ✅ 5. 상태(pageNum) 업데이트 -> 커스텀 훅(useBookSearch) useEffect 실행
          setPageNum(prev => prev + 1);
        }
      });
      if (node) observer.current.observe(node);
    },
    [isLoading, hasMore]
  );

  function handleSearch(e) {
    setQuery(e.target.value);
  }

  return (
    <>
      {/* ✅ 1. 상태(query) 업데이트 */}
      <input type="text" value={query} onChange={handleSearch} />
      {books.map((book, idx) => {
        if (books.length === idx + 1) {
          return (
            // 🟠 마지막 데이터인 경우에 예외 처리
            <div key={idx} ref={lastBookElementRef}>
              {book}
            </div>
          );
        }
        return <div key={idx}>{book}</div>;
      })}
      <div>{isLoading && <ClipLoader />}</div>
      <div>{isError && "Error"}</div>
    </>
  );
}

export default App;
// 📍 useBookSearch.js

import { useState, useEffect } from "react";
import axios from "axios";

export default function useBookSearch(query, pageNum) {
  const [isLoading, setIsLoading] = useState(true);
  const [isError, setIsError] = useState(false);
  const [books, setBooks] = useState([]);
  const [hasMore, setHasMore] = useState(false);

  useEffect(() => {
    setBooks([]);
  }, [query]);

  useEffect(() => {
    setIsLoading(true);
    setIsError(false);
    let cancel;
    axios({
      method: "GET",
      url: "http://openlibrary.org/search.json",
      params: { q: query, page: pageNum },
      // 🟠 axios 요청을 취소 (다른 요청을 보내는 중이라면)
      cancelToken: new axios.CancelToken(c => (cancel = c)),
    })
      .then(res => {
        // ✅ 3. 새로운 axios 요청에 대한 응답 처리
        setBooks(prevBooks => [
          ...new Set([...prevBooks, ...res.data.docs.map(book => book.title)]),
        ]);
        setHasMore(res.data.docs.length > 0);
        setIsLoading(false);
      })
      .catch(err => {
        // 🟠 axios 요청 취소 시 발생하는 에러 처리
        if (axios.isCancel(err)) return;
        setIsError(true);
      });
    return () => cancel();
    // ✅ 2. 상태(query) 업데이트로 인해 useEffect 실행
  }, [query, pageNum]);

  return { isLoading, isError, books, hasMore };
}

💁🏻 결과

profile
성장지향형 프론트엔드 개발자

0개의 댓글