[React] 무한스크롤 Custom Hook으로 만들기

김채운·2023년 3월 15일
3

React

목록 보기
17/26

아래는 원래 MainGrid 내에서 작성했던 intersection observer API를 통해 무한스크롤 기능을 구현한 코드이다. 이 코드를 통해서 오픈마켓의 메인 페이지의 상품들을 무한스크롤 기능으로 나열 했었다. 현재는 무한스크롤 기능을 MainGrid에서만 사용하지만 이게 진짜 쇼핑몰이라 생각한다면 검색기능이나 판매자 계정에서의 판매 상품들을 나열하는 부분에서도 사용 될 수 있기 때문에 커스텀 훅으로 따로 빼서 사용하는 연습을 해봤다.

👉 커스텀 훅 사용 전의 무한스크롤 코드


// MainGrid.js

function MainGrid() {
    const navigate = useNavigate()
    const [page, setPage] = useState(1)
    const [list, setList] = useState([])
    const [moreData, setMoreData] = useState(true)

    const getData = async () => {
        await api.get(`/products/?page=${page}`).then((res) => {
            setList((prev) => prev.concat(res.data.results))//리스트 추가
            setPage((prev) => prev + 1);
        }).catch((error) => {
            setMoreData(false)
            return;
        })
    }

useEffect(() => {
    let observer;
    const handleInterSect = async ([entry], observer) => {
        if (entry.isIntersecting) {
            observer.unobserve(entry.target);
            await getData()
            observer.observe(entry.target)
        }
    };
    observer = new IntersectionObserver(handleInterSect, { threshold: 0.6, });
    observer.observe(target.current) // 타겟 엘리먼트 지정
    return () => observer && observer.disconnect();
}, [])

    return (
        <Container>
            {
                list.map((p, i) => {
                    return <div key={i}>
                        <img src={p.image} alt="" onClick={() => navigate(`/detail/${p.product_id}`)} />
                        <p className='product-name'>{p.seller_store}</p>
                        <p className='product'>{p.product_name}</p>
                        <span className='product-price'>{p.price.toLocaleString()}</span>
                        <span></span>
                    </div>
                })
            }
            {moreData ? <div ref={target}></div> : null}
        </Container>
    )
}

👉 무한스크롤 커스텀 훅 코드

우선 프로젝트의 src 폴더 안에 hooks 폴더를 만들어 주고, use-infinitescroll.js 파일을 만들어 줬다. 파일의 이름은 어떻게 짓든 상관없다.
하지만, 커스텀 훅의 함수 이름은 use로 시작해야 하는 무조건적으로 지켜야 할 규칙이 있다. 그래서 함수 이름은 useInfiniteScroll로 정해줬다.

❗ 커스텀 훅의 함수 이름을 use로 시작해야 하는 이유

결국은 일반적인 함수지만, 이 이름앞에 붙인 use는 리액트에게 이 함수가 커스텀 훅임을 알려주는 역할을 한다. 리액트가 해당 함수를 훅의 규칙에 따라 사용하겠다고 보장해주는 것이다. 즉 이 커스텀 훅을 내장 훅과 같은 방식으로 쓰겠다는 것이다. 또한 프로젝트 셋업이 함수가 use로 시작하면서 훅의 규칙을 위반한 것이 발견되었을 때 경고를 보내줄 수 있다. 그래서 이는 무조건 지켜야 하는 규칙이다. 함수는 use로 시작하는 것이고 이후의 이름은 자유롭게 써도 된다.


//use-infinitescroll.js

import { useCallback, useEffect, useRef } from 'react';

function useInfiniteScroll(onIntersect) {
    const ref = useRef(null);

    const handleIntersect = useCallback(([entry], observer) => {
        if (entry.isIntersecting) {
            observer.unobserve(entry.target);
            onIntersect(entry, observer);
        }
    }, [onIntersect]);

  /*컴포넌트 렌더가 완료됨에 따라 observer가 생성되어야 하므로 useEffect를 활용해야 한다. 
  또한 target이 생성되기 전에 observe를 시작할 수 없으므로 조건문을 넣어줬다.*/
  
    useEffect(() => {
        let observer;
        if (ref.current) { // 관찰 대상이 존재하는 체크한다.
            observer = new IntersectionObserver(handleIntersect, { threshold: 0.6, }); // 관찰 대상이 존재한면 관찰자를 생성한다.
            observer.observe(ref.current); // 관찰자에게 타켓을 지정해준다.
        }
        return () => observer && observer.disconnect();
    }, [ref, handleIntersect]);

    return ref;
}

export default useInfiniteScroll;

✅ useInfiniteScroll hook은 콜백 함수를 인자로 받아서 IntersectionObserver를 초기화 해준다.

✅ callback 함수인 handleIntersect는 target을 주시하는 역할을 한다.

✅ 교차 상태가 변화했을 때, 교차된 target인 entry의 속성인 isIntersecting을 이용해서 교차 상태가 true일 때 인자로 넘어온 콜백 함수(onIntersect)를 실행시킨다.

✅ 사용자가 데이터 페칭이 완료되기 전에 교차 상태를 여러 번 변화시키는 상황이 발생하지 않도록 unobserve를 사용하여 관찰을 중단했다가 데이터 페칭이 완료되면 다시 observe를 하도록 했다.

✅ IntersectionObserver()에 handleIntersect를 콜백함수로 전달해주고, options로는 {threshold: 0.6}을 전달해줌으로써 관찰하고 있는 대상이 0.6만큼 화면에 보이면 handleIntersect가 실행되도록 했다.

✅ useEffect의 return에 disconnect()를 넣어줬는데, 그 이유는 데이터 페칭이 완료되고 나면 업데이트 로직이 끝나기 때문에 clean-up을 통해 observer의 관찰을 일시정지하고 버그를 방지하기 위함이다.

✅ 무한스크롤 기능은 ref로 target을 설정해줘서 그 target 지점에 도달하면 그 다음 데이터를 보여주게끔 하는 기능이다 그러므로 useRef훅을 사용해서 원하는 지점을 설정해줘야 하는데, MainGrid에서 useInfiniteScroll 훅을 사용할 수 있게 하려면 일단 이 ref에 접근할 수 있게 해야 된다. 방법은 간단하게 ref를 반환해주면 된다. 커스텀 훅에서는 필요한 모든 걸 반환할 수 있다. 그게 배열이든, 객체나 숫자든 모두 가능하다.

👉 무한스크롤 커스텀 훅 사용하기


// MainGrid.js

import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom';
import styled from "styled-components";
import useInfiniteScroll from '../hooks/use-infinitescroll';
import { api } from '../shared/api';

function MainGrid() {
    const navigate = useNavigate()
    const [page, setPage] = useState(1)
    const [list, setList] = useState([])
    const [moreData, setMoreData] = useState(true)

    const getData = async () => {
        await api.get(`/products/?page=${page}`).then((res) => {
            setList((prev) => prev.concat(res.data.results))//리스트 추가
            setPage((prev) => prev + 1);
        }).catch((error) => {
            setMoreData(false)
            return;
        })
    }

    const target = useInfiniteScroll(async (entry, observer) => {
        await getData()
    })

    return (
        <Container>
            {
                list.map((p, i) => {
                    return <div key={i}>
                        <img src={p.image} alt="" onClick={() => navigate(`/detail/${p.product_id}`)} />
                        <p className='product-name'>{p.seller_store}</p>
                        <p className='product'>{p.product_name}</p>
                        <span className='product-price'>{p.price.toLocaleString()}</span>
                        <span></span>
                    </div>
                })
            }
            {moreData ? <div ref={target}></div> : null}
        </Container>
    )
}

✅ useInfiniteScroll을 호출하고 있는 MainGrid 컴포넌트에서는 반환되는 값을 사용할 수 있다. 여기서 target을 변수로 지정하고 이를 useInfiniteScroll에 할당한다. 이렇게 하면 useInfiniteScroll이 target으로 값을 반환하기 때문에 컴포넌트 안의 변수에 저장할 수 있다.

출처

0개의 댓글