React - 무한스크롤 구현하기

da.circle·2023년 4월 19일
1

출처) Infinite Scrolling With React - Tutorial(YouTube)

책 검색 결과를 무한스크롤로 구현해보자

useBookSearch.jsx

export const useBookSearch = (query, pageNumber) => {
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(false);
  const [books, setBooks] = useState([]);
  const [hasMore, setHasMore] = useState(false);

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

  useEffect(() => {
    setLoading(true);
    setError(false);

    const controller = new AbortController();

    axios.get(
          `https://openlibrary.org/search.json?q=${query}&page=${pageNumber}`,
          {
            signal: controller.signal,
          }
        )
        .then(res => {
          setBooks(prevBooks => {
            return [
              ...new Set([...prevBooks, ...res.data.docs.map((b) => b.title)]),
            ];
          });
          setHasMore(res.data.length > 0);
          setLoading(false);
        })
        .catch(error => {
          if (error.name === 'AbortError') {
            return;
          }
          setError(true);
        });
      return () => controller.abort();
  }, [query, pageNumber]);
  return { loading, error, books, hasMore };
};

코드 설명

  useEffect(() => {
    setBooks([]);
  }, [query]);
  • uri의 query내용이 달라질 때 마다 책 목록을 초기화한다.
  • loading... 이 뜰 때 기존 검색 내역을 지우기 위함

// useEffect내
setLoading(true);
setError(false);
  • query 또는 pageNumber가 변경될 때 "loading..." 글자를 띄우기 위해 setLoadingtrue로 바꾼다.
  • query, pageNumber가 변경될 때는 에러가 발생하는게 아니므로 false처리한다.

const controller = new AbortController();
  • 요청 취소를 위해 AbortController 객체를 생성한다.

  • axios.get으로 책 목록을 서버에서 불러온다.
axios.get(
          `url?q=${query}&page=${pageNumber}`,
          {
            signal: controller.signal,
          }
        )
        .then(res => {
          setBooks(prevBooks => {
            return [
              ...new Set([...prevBooks, ...res.data.docs.map((b) => b.title)]),
            ];
          });
          setHasMore(res.data.docs.length > 0);
          setLoading(false);
        })
        .catch(error => {
          if (error.name === 'AbortError') {
            return;
          }
          setError(true);
        });
  • prevBooks : 검색 후 나온 결과 목록(스크롤 해서 데이터가 추가되기 직전 목록)
    → 내용이 중복되지 않도록 Set을 사용한다.
  • ...new Set([...prevBooks, ...res.data.docs.map((b) => b.title)])
    → 새로운 set에 기존 책 목록 + 새로운 책 목록을 넣는다.
    ...new Set : set 중복 제거
  • setHasMore(res.data.length > 0); : 0보다 크면 true, 더 이상 불러올 데이터가 없는 경우는 0이므로 false
  • setLoading(false); : 데이터를 불러온 후에는 loading false
  • if (axios.isCancel(error)) {return;} : 요청이 취소가 되면 return
  • setError(true); : 에러가 생기면 error true

useEffect(()=>{
  ...
  return { loading, error, books, hasMore };
},[])
  • 컴포넌트가 언마운트되는 경우 요청취소

return { loading, error, books, hasMore };
  • 책목록, 불러올 내용이 있는지 여부, 로딩 여부, 에러 여부를 반환한다.

App.js

import { useBookSearch } from './useBookSearch';

function App() {
  const [query, setQuery] = useState('');
  const [pageNumber, setPageNumber] = useState(1);

  const { books, hasMore, loading, error } = useBookSearch(query, pageNumber);

  const observer = useRef();

  const lastBookElementRef = useCallback(
    (node) => {
      if (loading) return;
      if (observer.current) observer.current.disconnect();

      observer.current = new IntersectionObserver((entries) => {
        console.log('entries[0].isIntersecting : ', entries[0].isIntersecting);
        if (entries[0].isIntersecting && hasMore) {
          setPageNumber((prevPageNumber) => prevPageNumber + 1);
        }
      });
      
      if (node) observer.current.observe(node);
      // console.log(node);
    },
    [loading, hasMore]
  );
  console.log('observer.current : ', observer.current);

  const handleSearch = (e) => {
    setQuery(e.target.value);
    setPageNumber(1);
  };

  return (
    <>
      <input type="text" value={query} onChange={handleSearch}></input>
      {books.map((book, index) => {
        if (books.length === index + 1) {
          return (
            <div ref={lastBookElementRef} key={book}>
              {book}
            </div>
          );
        } else {
          return (
            <Fragment key={book}>
              <div>index : {index}</div>
              <div>{book}</div>
            </Fragment>
          );
        }
      })}
      {loading && '...loading'}
      <div>{error && 'Error'}</div>
    </>
  );
}

export default App;

코드 설명

const [query, setQuery] = useState('');
const [pageNumber, setPageNumber] = useState(1);
  • query : useBookSearch에 props로 전달할 검색어
  • pageNumber : useBookSearch에 props로 전달할 페이지번호(1부터 시작)

const { books, hasMore, loading, error } = useBookSearch(query, pageNumber);
  • useBookSearch 컴포넌트에서 가져온 책목록, 불러올 내용이 있는지 여부, 로딩 여부, 에러 여부

const observer = useRef();

const lastBookElementRef = useCallback(
    (node) => {
      if (loading) return;
      if (observer.current) observer.current.disconnect();

      observer.current = new IntersectionObserver((entries) => {
        if (entries[0].isIntersecting && hasMore) {
          setPageNumber((prevPageNumber) => prevPageNumber + 1);
        }
      });
      
      if (node) observer.current.observe(node);
      // console.log(node);
    },
    [loading, hasMore]
  );
  • lastBookElementRef : loading과 hasMore의 값이 바뀔 때만 새로 실행된다.

  • node : 책 목록 중 마지막 요소

  • if (loading) return;

    • 로딩 중이면 굳이 무한스크롤을 트리거할 필요가 없으므로 return시킨다.(함수 종료)
    • 로딩 중에 따로 이렇게 처리하지 않으면 불필요한 API호출이 생길 수 있기 때문
  • if (observer.current) observer.current.disconnect();

    • observer.current가 있으면 IntersectionObserver가 관찰하는 모든 대상을 해제한다.
    • observer.current가 잡혔을 때 관찰 대상을 즉시 해제함으로서 올바른 마지막 요소를 연결할 수 있다.

      마지막 요소를 어떻게 구할까?
      jsx 부분에서 div에 ref={lastBookElementRef}으로 걸어놨는데, map 함수에서 if문 조건을 보면 다음과 같다.
      books.length === index + 1
      책 목록의 길이가 index에 1을 더한 값과 같아지면 그 때의 해당 요소가 책 목록 배열의 마지막 요소가 된다.

  • entries : IntersectionObserverEntry, IntersectionObserver가 사용할 수 있는 값들의 배열이다.

  • entries[0].isIntersecting : boolean값이며, 관찰중인 요소가 root(따로 설정하지 않았으므로 뷰포트) 영역 안에 들어왔는 지 여부이다.

    • 참고 ) isIntersecting은 관찰 대상이 루트 요소와 교차 상태로 들어가거나(true) 교차 상태에서 나가는지(false) 여부를 나타내는 값(Boolean)입니다.
    • 로딩중과 로딩 후에는 false이지만, 마지막 요소가 화면 안에 들어오면 true로 바뀌고, 로딩이 끝나면 다시 false가 된다.
  • if (entries[0].isIntersecting && hasMore)

    • isIntersection === true이고, 로딩할 데이터가 더 있다면(hasMore === true) 페이지 번호에 1을 더한다.
  • if (node) observer.current.observe(node);

    • 마지막 요소(node)가 잡히면 해당 요소의 관찰을 시작한다.
  • if (loading) return; : 로딩 중에 따로 이렇게 처리하지 않으면 불필요한 API호출이 생길 수 있기 때문에 return처리한다.


무한스크롤을 구현해야 해서 여기저기 찾아보다 찾은 유튜브 영상이다.
3년 전 영상이라 axios가 예전 버전이어서 조금 수정했다.
영상에 나온 openlibrary API는 검색어와 페이지를 url에 쿼리파라미터로 추가하면 해당하는 책 정보를 json으로 응답으로 보내준다. 무한스크롤 연습할 때 좋은 것 같다!
참고) Open Library Search API

profile
프론트엔드 개발자를 꿈꾸는 사람( •̀ ω •́ )✧

0개의 댓글