아래는 원래 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-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으로 값을 반환하기 때문에 컴포넌트 안의 변수에 저장할 수 있다.
출처