Intersection Observer API을 이용해서 Infinit Scroll을 구현해 보겠습니다. Intersection Observer API 동작 방법은 target element가 root element에 보이면 설정한 콜백함수가 실행됩니다.
useInfiniteScroll.tsx
import { useEffect, useRef } from 'react'
interface IProps {
rootMargin?: string
threshold?: number
disable: boolean
hasNextPage: boolean
fetchCallback: (page: number) => Promise<void>
}
export default function useInfiniteScroll({
rootMargin = '0px',
threshold = 0,
disable,
hasNextPage,
fetchCallback,
}: IProps) {
const page = useRef(1)
const rootRef = useRef<HTMLDivElement>(null)
const targetRef = useRef<HTMLLIElement>(null)
useEffect(() => {
if (!targetRef.current || disable || !hasNextPage) return undefined
const handleObserver = (entries: IntersectionObserverEntry[]) => {
const target = entries[0]
if (target.isIntersecting) {
page.current += 1
fetchCallback(page.current)
}
}
const option = {
root: rootRef.current,
rootMargin,
threshold,
}
const observer = new IntersectionObserver(handleObserver, option)
observer.observe(targetRef.current)
return () => {
observer && observer.disconnect()
page.current = 1
}
}, [disable, fetchCallback, hasNextPage, rootMargin, threshold])
return { targetRef, rootRef }
}
page
는 useRef를 이용했습니다. 이유는 useState를 사용하면 useEffect에 의존성 배열에 포함하고 fetchCallback
을 외부로 빼서 실행해야 되기 때문입니다.Component.tsx
import { useCallback, useEffect, useState } from 'react'
import { useRecoilState, useRecoilValue } from 'recoil'
import { searchInput, isInitSearch } from 'recoil/search'
import { ISearchItem } from 'types/search'
import { getSearchResApi } from 'services/search'
import useInfiniteScroll from 'hooks/useInfiniteScroll'
import SearchItem from './SearchItem'
import styles from './SearchResult.module.scss'
const fetchSearchData = async (s: string, page: number) => {
const { data } = await getSearchResApi({ s, page })
const { Search: searchItems, totalResults } = data
const endPage = Math.ceil(Number(totalResults) / 10)
return { searchItems, endPage }
}
export default function SearchResult() {
const [isInit, setIsInit] = useRecoilState(isInitSearch)
const searchInputVal = useRecoilValue(searchInput)
const [items, setItems] = useState<ISearchItem[]>([])
const [hasNextPage, setHasNextPage] = useState(false)
const fetchCallback = useCallback(
async (page: number) => {
if (searchInputVal === '') return
const { searchItems, endPage } = await fetchSearchData(searchInputVal, page)
setItems((prev) => [...prev, ...searchItems])
setHasNextPage(page < endPage)
},
[searchInputVal]
)
const { targetRef } = useInfiniteScroll({
disable: items.length === 0,
hasNextPage,
fetchCallback,
})
useEffect(() => {
if (!isInit) return
setHasNextPage(false)
setItems([])
setIsInit(false)
}, [isInit, setIsInit])
useEffect(() => {
fetchCallback(1)
}, [fetchCallback])
return (
<article className={styles.wrapper}>
{items.length === 0 ? (
<div>검색 결과가 없습니다.</div>
) : (
<ul>
{items.map((item: ISearchItem, index) => {
const key = `searchItem-${index}`
return <SearchItem key={key} item={item} />
})}
<li className={styles.endPoint} ref={targetRef} />
</ul>
)}
</article>
)
}
fetchCallback
에서 화면에 출력할 items
와 다음 페이지가 있는 여부를 나타내는 hasNextPage
에 값을 할당합니다.infinite-scroll
intersection-observer 1
intersection-observer 2