[Next.js] DataTable + 무한스크롤 적용기

youznn·2024년 8월 6일
0

Next JS

목록 보기
1/4


Tanstack DataTable에 무한 스크롤을 구현한 방법을 알아볼까요? 😊🥳


도입 배경

codedang은 학생들의 코딩 실력 향상을 돕는 Online judge 플랫폼입니다! 저희는 ux향상을 위해 problem 페이지를 무한스크롤로 구현하기로 하였습니다.

👍 무한 스크롤의 장점
infinite scrolling은 UX면에서 큰 장점을 보입니다. 특히, 모바일 사용자에게는 스크롤을 내리는 것이 탭하는 것보다 쉽습니다. 또한 데이터를 사용자의 액션에 따라 동적으로 분할하여 페칭할 수 있습니다.

👎 무한 스크롤의 단점
원하는 페이지로 이동하기 힘들고, 사용자의 상호작용에 따라 fetching을 요청하기 때문에 client component로 사용해야 합니다. 따라서 SEO의 성능이 떨어질 수 있습니다.

그럼에도 저희 팀은 problem page에서 향상된 사용자 경험을 유지하기 위해 무한 스크롤을 선택하였습니다. 페이지네이션과 무한스크롤은 모두 커서 기반입니다!

Interaction && DataTable data update

./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가 리턴되며, 해당 리턴값들을 사용하고 있습니다.

itemDataTable에 사용되는 data 값입니다.

특히, ref는 테이블의 하단div 값으로 주어 사용자의 스크롤을 감지합니다. 또한 isFetchingNextPage에 의해 Suspense와 유사하게 페칭 중일 시 스켈레톤이 실행됩니다.

UseInfiniteScroll hook

무한스크롤을 다른 곳에도 적용하기 위해 UseInfiniteScroll이라는 custom hook을 만들었습니다. 해당 코드의 내용 자체는 어렵지 않지만, 저희 프로젝트에 적용하기 위해 고려해야 할 부분이 몇 가지 있었습니다.

  1. 로그인 시에는 session을 고려한 fetching을 해야 함
  2. problem, contest, notice 등 각 data의 타입을 고려해야 함
  3. 검색 기능이 있기 때문에 search parameter(query)를 고려해야 함

UseInfiniteScroll.tsx

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로 전달받습니다.

useSuspenseInfiniteQuery

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이 사용됩니다. pageParamgetNextPageParam에 의해 페칭 시 바뀝니다.

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를 볼 수 있을 것입니다. 저희 프로젝트 기획에서는 제외되었지만, 다른 프로젝트에는 유용할 것 같습니다!

Page: 마지막으로 주의할 점

'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에 대해 더 열심히 공부해야봐야겠네요!

profile
https://github.com/youznn

0개의 댓글