From 4ab66a9018154438445f003c2b20df4bb3dbabc6 Mon Sep 17 00:00:00 2001 From: Eunbi Kang Date: Fri, 29 Nov 2024 22:00:45 +0900 Subject: [PATCH] refactor(fe): refactor infinite scroll with tanstack query (#2244) * feat(fe): add problem infinite list query option * feat(fe): add intersection area component * refactor(fe): refactor problem infinite table * feat(fe): add tanstack query error boundary * fix(fe): pass itemsPerPage parameter * chore(fe): use common error fallback ui --- .../_components/ProblemInfiniteTable.tsx | 68 +++++----- .../app/(client)/(main)/problem/page.tsx | 45 ++----- .../(client)/_libs/queries/infiniteQuery.ts | 48 +++++++ .../app/(client)/_libs/queries/problem.ts | 16 +++ apps/frontend/components/IntersectionArea.tsx | 29 +++++ .../components/TanstackQueryErrorBoundary.tsx | 28 +++++ apps/frontend/libs/hooks/useInfiniteScroll.ts | 118 ------------------ 7 files changed, 172 insertions(+), 180 deletions(-) create mode 100644 apps/frontend/app/(client)/_libs/queries/infiniteQuery.ts create mode 100644 apps/frontend/app/(client)/_libs/queries/problem.ts create mode 100644 apps/frontend/components/IntersectionArea.tsx create mode 100644 apps/frontend/components/TanstackQueryErrorBoundary.tsx delete mode 100644 apps/frontend/libs/hooks/useInfiniteScroll.ts diff --git a/apps/frontend/app/(client)/(main)/problem/_components/ProblemInfiniteTable.tsx b/apps/frontend/app/(client)/(main)/problem/_components/ProblemInfiniteTable.tsx index baf2ffe635..b1ce873f5d 100644 --- a/apps/frontend/app/(client)/(main)/problem/_components/ProblemInfiniteTable.tsx +++ b/apps/frontend/app/(client)/(main)/problem/_components/ProblemInfiniteTable.tsx @@ -1,9 +1,10 @@ 'use client' import DataTable from '@/app/(client)/(main)/_components/DataTable' +import { problemQueries } from '@/app/(client)/_libs/queries/problem' +import IntersectionArea from '@/components/IntersectionArea' import { Skeleton } from '@/components/shadcn/skeleton' -import { useInfiniteScroll } from '@/libs/hooks/useInfiniteScroll' -import type { Problem } from '@/types/type' +import { useSuspenseInfiniteQuery } from '@tanstack/react-query' import { useSearchParams } from 'next/navigation' import SearchBar from '../../_components/SearchBar' import { columns } from './Columns' @@ -12,46 +13,57 @@ 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({ - pathname: 'problem', - query: newSearchParams - }) + const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = + useSuspenseInfiniteQuery(problemQueries.infiniteList({ search, order })) return ( <>

All

-

{total}

+

{data.total}

- - {isFetchingNextPage && ( - <> - {[...Array(5)].map((_, i) => ( + + + {isFetchingNextPage && + [...Array(5)].map((_, i) => ( ))} - - )} -
+
) } + +export function ProblemInfiniteTableFallback() { + return ( + <> +
+ + + +
+ {[...Array(5)].map((_, i) => ( + + ))} + + ) +} diff --git a/apps/frontend/app/(client)/(main)/problem/page.tsx b/apps/frontend/app/(client)/(main)/problem/page.tsx index 3708595e75..eba540a4d1 100644 --- a/apps/frontend/app/(client)/(main)/problem/page.tsx +++ b/apps/frontend/app/(client)/(main)/problem/page.tsx @@ -1,39 +1,16 @@ -'use client' - -import { Skeleton } from '@/components/shadcn/skeleton' -import type { Problem } from '@/types/type' +import FetchErrorFallback from '@/components/FetchErrorFallback' +import { TanstackQueryErrorBoundary } from '@/components/TanstackQueryErrorBoundary' import { Suspense } from 'react' -import ProblemInfiniteTable from './_components/ProblemInfiniteTable' +import ProblemInfiniteTable, { + ProblemInfiniteTableFallback +} from './_components/ProblemInfiniteTable' -export default function Problem() { +export default function ProblemListPage() { return ( - -
- - - - - - - - - - - - - - - -
- {[...Array(5)].map((_, i) => ( - - ))} - - } - > - -
+ + }> + + + ) } diff --git a/apps/frontend/app/(client)/_libs/queries/infiniteQuery.ts b/apps/frontend/app/(client)/_libs/queries/infiniteQuery.ts new file mode 100644 index 0000000000..15fbedeb64 --- /dev/null +++ b/apps/frontend/app/(client)/_libs/queries/infiniteQuery.ts @@ -0,0 +1,48 @@ +import { + infiniteQueryOptions, + type QueryKey, + type UnusedSkipTokenInfiniteOptions +} from '@tanstack/react-query' + +interface Item { + id: number +} + +interface DataSet { + data: T[] + total: number +} + +type PageParam = number | undefined + +interface GetInfiniteQueryOptionsParams + extends Partial< + UnusedSkipTokenInfiniteOptions, Error, DataSet, K, PageParam> + > { + queryKey: K + itemsPerPage?: number +} + +export const getInfiniteQueryOptions = ({ + queryFn, + queryKey, + itemsPerPage = 10 +}: GetInfiniteQueryOptionsParams) => { + return infiniteQueryOptions({ + queryKey, + queryFn, + staleTime: 0, + initialPageParam: undefined, + getNextPageParam: (lastPage) => { + return lastPage.data.length === 0 || lastPage.data.length < itemsPerPage + ? undefined //페이지에 있는 아이템 수가 0이거나 itemsPerPage보다 작으면 undefined를 반환합니다. + : lastPage.data.at(-1)?.id //cursor를 getData의 params로 넘겨줍니다. + }, + select: (data) => { + const items = data.pages.flat().flatMap((page) => page.data) + const total = data.pages.at(0)?.total + + return { items, total } + } + }) +} diff --git a/apps/frontend/app/(client)/_libs/queries/problem.ts b/apps/frontend/app/(client)/_libs/queries/problem.ts new file mode 100644 index 0000000000..788b9b04b9 --- /dev/null +++ b/apps/frontend/app/(client)/_libs/queries/problem.ts @@ -0,0 +1,16 @@ +import { getProblemList, type GetProblemListRequest } from '../apis/problem' +import { getInfiniteQueryOptions } from './infiniteQuery' + +export const problemQueries = { + all: () => ['problem'] as const, + infiniteList: (params: GetProblemListRequest) => + getInfiniteQueryOptions({ + queryKey: [...problemQueries.all(), 'list'] as const, + queryFn: ({ pageParam }) => + getProblemList({ + ...params, + ...(pageParam ? { cursor: pageParam } : undefined) + }), + itemsPerPage: params.take + }) +} diff --git a/apps/frontend/components/IntersectionArea.tsx b/apps/frontend/components/IntersectionArea.tsx new file mode 100644 index 0000000000..8c9dc40be0 --- /dev/null +++ b/apps/frontend/components/IntersectionArea.tsx @@ -0,0 +1,29 @@ +import { useEffect, type ReactNode } from 'react' +import { useInView } from 'react-intersection-observer' + +interface IntersectionAreaProps { + children: ReactNode + disabled: boolean + onIntersect: () => void +} + +export default function IntersectionArea({ + children, + disabled, + onIntersect +}: IntersectionAreaProps) { + const { ref, inView } = useInView() + + useEffect(() => { + if (inView && !disabled) { + onIntersect() + } + }, [inView, disabled]) + + return ( + <> + {children} +
+ + ) +} diff --git a/apps/frontend/components/TanstackQueryErrorBoundary.tsx b/apps/frontend/components/TanstackQueryErrorBoundary.tsx new file mode 100644 index 0000000000..3cd1136cf1 --- /dev/null +++ b/apps/frontend/components/TanstackQueryErrorBoundary.tsx @@ -0,0 +1,28 @@ +'use client' + +import { + ErrorBoundary, + type ErrorBoundaryFallbackProps +} from '@suspensive/react' +import { QueryErrorResetBoundary } from '@tanstack/react-query' +import type { FunctionComponent, ReactNode } from 'react' + +interface TanstackQueryErrorBoundaryProps { + children: ReactNode + fallback: FunctionComponent> +} + +export function TanstackQueryErrorBoundary({ + children, + fallback +}: TanstackQueryErrorBoundaryProps) { + return ( + + {({ reset }) => ( + + {children} + + )} + + ) +} diff --git a/apps/frontend/libs/hooks/useInfiniteScroll.ts b/apps/frontend/libs/hooks/useInfiniteScroll.ts deleted file mode 100644 index f29d676123..0000000000 --- a/apps/frontend/libs/hooks/useInfiniteScroll.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { fetcher, fetcherWithAuth } from '@/libs/utils' -import { useSuspenseInfiniteQuery } from '@tanstack/react-query' -import { useEffect } from 'react' -import { useInView } from 'react-intersection-observer' -import type { URLSearchParams } from 'url' - -interface Item { - id: number -} - -interface DataSet { - data: T[] - total: number -} - -interface UseInfiniteScrollProps { - pathname: string - query: URLSearchParams - itemsPerPage?: number - withAuth?: boolean -} - -/** - * - * @param pathname - * The path or endpoint from which data will be fetched. - * - * @param query - * The URLSearchParams object representing the query parameters for data fetching. - * - * @param itemsPerPage - * The number of items to fetch per page. Default is 5. - * - * @param withAuth - * A boolean indicating whether authentication is required for data fetching. Default is false. - * - */ - -export const useInfiniteScroll = ({ - pathname, - query, - itemsPerPage = 10, - withAuth = false -}: UseInfiniteScrollProps) => { - //fetch datas with pageParams and url - const getInfiniteData = async ({ - pageParam - }: { - pageParam?: number - }): Promise> => { - if (!query.has('take')) query.append('take', String(itemsPerPage)) - pageParam && pageParam > 0 && query.set('cursor', pageParam.toString()) - let dataSet: DataSet - withAuth - ? (dataSet = await fetcherWithAuth - .get(pathname, { - searchParams: query - }) - .json()) - : (dataSet = await fetcher - .get(pathname, { - searchParams: query - }) - .json()) - return dataSet - } - - const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = - useSuspenseInfiniteQuery({ - queryKey: [pathname, query.toString()], - staleTime: 0, - queryFn: getInfiniteData, - initialPageParam: 0, - getNextPageParam: (lastPage: DataSet) => { - return lastPage?.data.length === 0 || //TODO: fix problem to data - lastPage?.data.length < itemsPerPage - ? undefined //페이지에 있는 아이템 수가 0이거나 itemsPerPage보다 작으면 undefined를 반환합니다. - : lastPage.data.at(-1)?.id //cursor를 getData의 params로 넘겨줍니다. - } - }) - - const { ref, inView } = useInView() - - useEffect(() => { - if (inView && !isFetchingNextPage && hasNextPage) { - fetchNextPage() - } - }, [inView, isFetchingNextPage, hasNextPage, fetchNextPage, data]) - - /* - - 5번 이상 바닥에 닿으면 자동 페칭을 멈추고, loadmore 버튼을 보이게 합니다. - const scrollCounter = useRef(0) // 바닥에 닿은 횟수를 세는 카운터 - const [isLoadButton, setIsLoadButton] = useState(false) - - useEffect(() => { - if (inView && !isFetchingNextPage) { - if (hasNextPage) { - if (scrollCounter.current < 5) { - setIsLoadButton(false) - fetchNextPage() - scrollCounter.current += 1 - } else { - setIsLoadButton(true) - } - } - } else setIsLoadButton(false) - }, [inView, isFetchingNextPage, hasNextPage, fetchNextPage, data]) -*/ - - return { - items: data.pages.flat().flatMap((page) => page.data), - total: data.pages.at(0)?.total, - fetchNextPage, - ref, - isFetchingNextPage - } -}