요구사항은 다음과 같았다.
api한번 호출당 100개의 데이터를 호출하고, 이를 10개씩 끊어서 보여주고, 100개의 데이터가 끝난 후에는 다음페이지의 api를 다시 호출하고 (100개) 마찬가지로 10개씩 보여주면 됐다.
회원별로 각기 다른 응답값이 반환될테지만, 그런 파라미터는 고려하지 않고 예제 훅을 만들어서 적용해보겠다.
먼저 인피니티 스크롤의 관례적인 데이터 형식을 가지는 pokeAPI로 테스트를 진행했다.
기본적으로 api호출은 react-query의 useInfiniteQuery를 사용하였고,
스크롤마다 다음 api가 호출될수 있도록, fetchNextPage의 트리거역할을 위해 IntersectionObserver를 결합하여 구현하였다.
import { GetNextPageParamFunction, InfiniteData, useInfiniteQuery } from '@tanstack/react-query'
// 인피니티 스크롤의 관례적인 데이터 형식을 따른다
type ResponseType<T> = {
results: T[] // 실제 각각의 데이터
next?: string // 다음 페이지의 api주소를 담는
}
type InfiniteApiProps<TData> = {
defaultPageParam: string
queryKey: string[]
getNextPageParam: GetNextPageParamFunction<string, ResponseType<TData>>
select?: (
data: InfiniteData<ResponseType<TData>, string>
) => InfiniteData<ResponseType<TData>, string>
}
export const useInfiniteApi = <TData, TError = Error>({
defaultPageParam,
queryKey,
getNextPageParam,
select
}: InfiniteApiProps<TData>) => {
return useInfiniteQuery<
ResponseType<TData>,
TError,
InfiniteData<ResponseType<TData>, string>,
string[],
string
>({
queryKey,
queryFn: async ({ pageParam }) => {
const response = await fetch(pageParam)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
return response.json()
},
initialPageParam: defaultPageParam,
getNextPageParam,
select
})
}
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { InfiniteData } from '@tanstack/react-query'
type InfiniteDisplayProps<TData> = {
data?: InfiniteData<{ results: TData[] }, string>
isFetchingNextPage: boolean
fetchNextPage: () => Promise<unknown>
initialCount: number
hasNextPage: boolean
}
export const useInfiniteDisplay = <TData>({
data,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
initialCount
}: InfiniteDisplayProps<TData>) => {
const [displayCount, setDisplayCount] = useState(initialCount)
const observerRef = useRef<HTMLDivElement | null>(null)
const displayResult = useMemo(() => {
if (!data?.pages) return []
const totalResult = data.pages.flatMap((page) => page.results)
return totalResult.slice(0, displayCount)
}, [data?.pages, displayCount])
const handleObserver = useCallback(
async (entries: IntersectionObserverEntry[]) => {
const [entry] = entries
if (entry.isIntersecting) {
const resultLength = data?.pages.flatMap((page) => page.results).length || 0
if (displayCount < resultLength) {
setDisplayCount((prev) => prev + initialCount)
}
if (displayCount >= resultLength && hasNextPage && !isFetchingNextPage) {
await fetchNextPage()
}
}
},
[data?.pages, displayCount, hasNextPage, isFetchingNextPage, fetchNextPage, initialCount]
)
useEffect(() => {
const observer = new IntersectionObserver(handleObserver, {
root: null,
rootMargin: '20px',
threshold: 0.1
})
if (observerRef.current) {
observer.observe(observerRef.current)
}
return () => observer.disconnect()
}, [handleObserver])
return {
displayResult,
observerRef
} as const
}
useInfiniteDisplay.ts훅은 useInfiniteApi와 같이 사용하도록 분리한 훅으로, initialCount를 받아서 각 api호출마다 원하는 데이터 숫자만큼만 나눠서 보여줄 수 있도록 구현했다. (displayResult)
그럴 필요가 없다면, initialCount를 호출되는 데이터의 갯수만큼 적용하면 그만이다.
또한 observerRef는 실제 tsx엘리먼트에 ref속성으로 사용해서 해당 위치에 스크롤이 닿을 경우 fetchNextPage()를 실행시킨다.
import { useInfiniteApi } from './hooks/useInfiniteApi'
import { useInfiniteDisplay } from './hooks/useInfiniteDisplay'
const defaultPageParam = 'https://pokeapi.co/api/v2/pokemon?limit=100'
type DisplayItemType = {
name: string,
url: string
}
function App() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteApi<DisplayItemType>({
defaultPageParam,
queryKey: ['poke-list'],
getNextPageParam: (lastPage) => {
return lastPage.next
},
})
const { displayResult, observerRef } = useInfiniteDisplay<DisplayItemType>({
data,
fetchNextPage,
hasNextPage,
initialCount: 10,
isFetchingNextPage
})
console.log(data)
return (
<>
<ul>
{displayResult.map((item) => {
return <li key={item.url}>{item.name}</li>
})}
</ul>
<div ref={observerRef}></div>
</>
)
}
export default App
잘 작동한다. pokeAPI가 아닌 실제 벡엔드 개발자와 협업할때에도 아래의 데이터형식으로 요구하면 무리없이 위의 훅을 사용할 수 있다.
type ResponseType<T> = {
results: T[] // 실제 각각의 데이터
next?: string // 다음 페이지의 api주소를 담는
}
만약 api의 응답값 타입이
type ResponseType<T> = {
results: T[] // 실제 각각의 데이터
next?: string // 다음 페이지의 api주소를 담는
}
이렇지 않고 results를 객체로 취급할 경우
type ResponseType<T> = {
results: { [key: string]: T[] }
next?: string
}
동일한 퍼포먼스를 내기 위해 useInfiniteDisplay훅을 수정해야한다
// useInfiniteDisplay.ts 중
const displayResult = useMemo(() => {
if (!data?.pages) return []
const totalResult = data.pages.flatMap((page) => {
const resultMap = new Map()
Object.entries(page.results).forEach(([key, value]) => {
resultMap.set(key, value)
})
return Array.from(resultMap.values()).flat()
})
return totalResult.slice(0, displayCount)
}, [data?.pages, displayCount])
const handleObserver = useCallback(
async (entries: IntersectionObserverEntry[]) => {
const [entry] = entries
if (entry.isIntersecting) {
const resultLength =
data?.pages.flatMap((page) => {
return Object.values(page.results).flat()
}).length || 0
...