Tanstack DataTable에 무한 스크롤을 구현한 방법을 알아볼까요? 😊🥳
codedang은 학생들의 코딩 실력 향상을 돕는 Online judge 플랫폼입니다! 저희는 ux향상을 위해 problem 페이지를 무한스크롤로 구현하기로 하였습니다.
👍 무한 스크롤의 장점
infinite scrolling은 UX면에서 큰 장점을 보입니다. 특히, 모바일 사용자에게는 스크롤을 내리는 것이 탭하는 것보다 쉽습니다. 또한 데이터를 사용자의 액션에 따라 동적으로 분할하여 페칭할 수 있습니다.
👎 무한 스크롤의 단점
원하는 페이지로 이동하기 힘들고, 사용자의 상호작용에 따라 fetching을 요청하기 때문에 client component로 사용해야 합니다. 따라서 SEO의 성능이 떨어질 수 있습니다.
그럼에도 저희 팀은 problem page에서 향상된 사용자 경험을 유지하기 위해 무한 스크롤을 선택하였습니다. 페이지네이션과 무한스크롤은 모두 커서 기반입니다!
./problem/page.tsx
먼저 problem page의 코드를 보자면 다음과 같습니다.
'use client'
import DataTable from '@/components/DataTable'
import SearchBar from '@/components/SearchBar'
import { Skeleton } from '@/components/ui/skeleton'
import { useInfiniteScroll } from '@/lib/useInfiniteScroll'
import type { Problem } from '@/types/type'
import { useSearchParams } from 'next/navigation'
import { columns } from './Columns'
export default function ProblemInfiniteTable() {
const searchParams = useSearchParams()
const search = searchParams.get('search') ?? ''
const order = searchParams.get('order') ?? 'id-asc'
const newSearchParams = new URLSearchParams()
newSearchParams.set('search', search)
newSearchParams.set('order', order)
const { items, total, ref, isFetchingNextPage } = useInfiniteScroll<Problem>({
pathname: 'problem',
query: newSearchParams
})
return (
<>
<div className="flex items-center justify-between text-gray-500">
<div className="flex gap-1">
<p className="text-2xl font-bold text-gray-500">All</p>
<p className="text-2xl font-bold text-blue-500">{total}</p>
</div>
<SearchBar />
</div>
<div className="flex flex-col items-center">
<DataTable
data={items}
columns={columns}
headerStyle={{
title: 'text-left w-5/12',
difficulty: 'w-2/12',
submissionCount: 'w-2/12',
acceptedRate: 'w-2/12',
info: 'w-1/12'
}}
linked
/>
{isFetchingNextPage && (
<>
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="my-2 flex h-12 w-full rounded-xl" />
))}
</>
)}
<div ref={ref} />
</div>
</>
)
}
해당 페이지에서는 useInfiniteScroll
커스텀 훅에 의해 item
, total
, ref
, isFetchingNextPage
가 리턴되며, 해당 리턴값들을 사용하고 있습니다.
item
은 DataTable
에 사용되는 data 값입니다.
특히, ref
는 테이블의 하단div
값으로 주어 사용자의 스크롤을 감지합니다. 또한 isFetchingNextPage
에 의해 Suspense와 유사하게 페칭 중일 시 스켈레톤이 실행됩니다.
무한스크롤을 다른 곳에도 적용하기 위해 UseInfiniteScroll
이라는 custom hook을 만들었습니다. 해당 코드의 내용 자체는 어렵지 않지만, 저희 프로젝트에 적용하기 위해 고려해야 할 부분이 몇 가지 있었습니다.
interface Item {
id: number
}
interface DataSet<T> {
data: T[]
total: number
}
interface UseInfiniteScrollProps {
pathname: string
query: URLSearchParams
itemsPerPage?: number
withAuth?: boolean
}
export const useInfiniteScroll = <T extends Item>({
pathname,
query,
itemsPerPage = 10,
withAuth = false
}: UseInfiniteScrollProps)
사용하는 interface는 총 세 가지입니다.
첫 번째 interface인 Item
은 Typescript의 제네릭을 이용하여 id가 필수로 포함된 type을 가져오기 위해 사용됩니다.
Dataset<T>
는 json 데이터 형식을 위해 사용합니다. 저희 프로젝트의 특정 데이터들의 필드 형식은 data와 total로 통합하여 사용합니다. 아래는 요청 시 받는 problem의 data 형식입니다.
{
"data": [
{
"id": 2,
"title": "가파른 경사",
"engTitle": null,
"difficulty": "Level1",
"submissionCount": 8,
"acceptedRate": 0,
"tags": [
{
"id": 2,
"name": "Iteration"
}
]
}
],
"total": 6
}
마지막으로, Props는
api의 경로, URLsearchParams 형태인 쿼리, 한번에 가져올 아이템의 개수, 마지막으로 auth가 필요한 fetching인지의 여부를 props로 전달받습니다.
Tanstack Query 라이브러리의 함수입니다. https://tanstack.com/query/latest/docs/framework/react/reference/useSuspenseInfiniteQuery#usesuspenseinfinitequery
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useSuspenseInfiniteQuery({
queryKey: [pathname, query.toString()],
staleTime: 0,
queryFn: getInfiniteData,
initialPageParam: 0,
getNextPageParam: (lastPage: DataSet<T>) => {
return lastPage?.data.length === 0 ||
lastPage?.data.length < itemsPerPage
? undefined
//페이지에 있는 아이템 수가 0이거나 itemsPerPage보다 작으면 undefined를 반환합니다.
: lastPage.data.at(-1)?.id
//cursor를 getData의 params로 넘겨줍니다.
}
})
기존의 Tanstack Query의 useInfiniteQuery
에는 react의 suspense가 적용되지 않습니다. 따라서 useSuspenseInfiniteQuery
를 사용하였습니다.
queryKey
: queryKey
를 기준으로 fetching을 재시작합니다. 위 코드에서는 pathname과 query가 바뀔 때마다 fetching을 재시작합니다.
queryFn
: Fetching 함수입니다. 위 코드에서는 getInfniteData
함수를 사용합니다. 이때의 parameter로는 pageParam
이 사용됩니다. pageParam
은 getNextPageParam
에 의해 페칭 시 바뀝니다.
getinfiniteData
const getInfiniteData = async ({
pageParam
}: {
pageParam?: number
}): Promise<DataSet<T>> => {
if (!query.has('take')) query.append('take', String(itemsPerPage))
pageParam && pageParam > 0 && query.set('cursor', pageParam.toString())
let dataSet: DataSet<T>
withAuth
? (dataSet = await fetcherWithAuth
.get(pathname, {
searchParams: query
})
.json())
: (dataSet = await fetcher
.get(pathname, {
searchParams: query
})
.json())
return dataSet
}
만약 query에 take가 지정되지 않았다면, 해당 훅의 props인 itemsPerPage를 take로 사용합니다. 또한 withAuth 옵션이 있다면 fetcherWithAuth로 데이터를 페칭하고, 없다면 fetcher를 이용하도록 구현하였습니다.
import { useInView } from 'react-intersection-observer'
const { ref, inView } = useInView()
useEffect(() => {
if (inView && !isFetchingNextPage && hasNextPage) {
fetchNextPage()
}
}, [inView, isFetchingNextPage, hasNextPage, fetchNextPage, data])
useInView
를 사용하면 사용자의 행동을 쉽게 감지할 수 있습니다. 저는 useEffect
내에 inview를 넣어, inview가 감지될 경우 && 다음 페이지를 페칭 중일 때가 아닌 경우 && 다음 페이지가 있는 경우에 fetchNextPage()
함수를 불러와 다음 페이지를 페칭합니다.
'N번 이상' 데이터를 페칭했을 경우, 무한 스크롤을 멈추고 'loadmore' 버튼을 보이게 할 수도 있습니다.이를 사용하면 사용자는 footer를 볼 수 있을 것입니다. 저희 프로젝트 기획에서는 제외되었지만, 다른 프로젝트에는 유용할 것 같습니다!
'use client'
import { Skeleton } from '@/components/ui/skeleton'
import type { Problem } from '@/types/type'
import { QueryClientProvider, QueryClient } from '@tanstack/react-query'
import { Suspense } from 'react'
import ProblemInfiniteTable from './_components/ProblemInfiniteTable'
export default function Problem() {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false
}
}
})
return (
<>
<QueryClientProvider client={queryClient}>
<Suspense
fallback={
<>
<div className="mt-4 flex">
<span className="w-5/12">
<Skeleton className="h-6 w-20" />
</span>
<span className="w-2/12">
<Skeleton className="mx-auto h-6 w-20" />
</span>
<span className="w-2/12">
<Skeleton className="mx-auto h-6 w-20" />
</span>
<span className="w-2/12">
<Skeleton className="mx-auto h-6 w-20" />
</span>
<span className="w-1/12">
<Skeleton className="mx-auto h-6 w-12" />
</span>
</div>
{[...Array(5)].map((_, i) => (
<Skeleton
key={i}
className="my-2 flex h-12 w-full rounded-xl"
/>
))}
</>
}
>
<ProblemInfiniteTable />
</Suspense>
</QueryClientProvider>
</>
)
}
useQuery를 사용하기 위해서는 QueryClientProvider로 페이지에 바운더리를 지정해주어야 합니다. 또한 저는 탭 이동 후 다시 돌아왔을 때, 원치 않는 페칭이 진행되는 경우가 있었습니다.
이를 대비하기 위해 queryClient
옵션에 refetchOnWindowFocus: false
를 추가하였습니다.
해당 훅을 구현한 지도 5개월이 넘었습니다. 당시 구현할 때 수많은 레퍼런스와 공식 문서를 보며 많은 삽질( ㅠㅠ ) 을 했던 기억이 있었습니다. 특히 타입스크립트가 익숙하지 않아 타입 관련해서 애를 먹었습니다 😭
tanstack query에 대해 더 열심히 공부해야봐야겠네요!