summaryrefslogtreecommitdiff
path: root/hooks/use-data-table.ts
diff options
context:
space:
mode:
Diffstat (limited to 'hooks/use-data-table.ts')
-rw-r--r--hooks/use-data-table.ts315
1 files changed, 256 insertions, 59 deletions
diff --git a/hooks/use-data-table.ts b/hooks/use-data-table.ts
index a3301067..f33c2e8b 100644
--- a/hooks/use-data-table.ts
+++ b/hooks/use-data-table.ts
@@ -1,3 +1,4 @@
+// hooks/use-data-table.ts (업데이트)
"use client"
import * as React from "react"
@@ -31,10 +32,21 @@ import {
type Parser,
type UseQueryStateOptions,
} from "nuqs"
+import useSWRInfinite from "swr/infinite"
import { getSortingStateParser } from "@/lib/parsers"
import { useDebouncedCallback } from "@/hooks/use-debounced-callback"
+// 무한 스크롤 임계값 (이 값 이상이면 무한 스크롤 모드)
+const INFINITE_SCROLL_THRESHOLD = 1_000_000
+
+// 무한 스크롤 설정
+interface InfiniteScrollConfig {
+ apiEndpoint: string
+ tableName: string
+ maxPageSize?: number
+}
+
interface UseDataTableProps<TData>
extends Omit<
TableOptions<TData>,
@@ -60,15 +72,22 @@ interface UseDataTableProps<TData>
clearOnDefault?: boolean
initialState?: Omit<Partial<TableState>, "sorting"> & {
sorting?: ExtendedSortingState<TData>
- /**
- * 기본 그룹핑 컬럼 배열 (멀티 그룹핑)
- */
grouping?: string[]
- /**
- * 그룹 확장/접기 상태
- */
expanded?: Record<string, boolean>
}
+ // 기존 데이터 (페이지네이션 모드용)
+ data?: TData[]
+ // 무한 스크롤 설정 (pageSize 기반 자동 활성화)
+ infiniteScrollConfig?: InfiniteScrollConfig
+}
+
+// 무한 스크롤 응답 타입
+interface InfiniteScrollResponse<TData> {
+ mode: 'infinite'
+ data: TData[]
+ hasNextPage: boolean
+ nextCursor: string | null
+ total?: number | null
}
export function useDataTable<TData>({
@@ -83,8 +102,11 @@ export function useDataTable<TData>({
clearOnDefault = false,
startTransition,
initialState,
+ data: initialData = [],
+ infiniteScrollConfig,
...props
}: UseDataTableProps<TData>) {
+
// 공통 URL QueryState 옵션
const queryStateOptions = React.useMemo<
Omit<UseQueryStateOptions<string>, "parse">
@@ -108,14 +130,14 @@ export function useDataTable<TData>({
startTransition,
])
- // -------- RowSelection & ColumnVisibility는 URL 동기화 없이 로컬 상태 ----------
+ // -------- 기존 상태들 --------
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>(
initialState?.rowSelection ?? {}
)
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>(initialState?.columnVisibility ?? {})
- // -------- Pagination (page, perPage) URL 동기화 --------
+ // -------- Pagination URL 동기화 --------
const [page, setPage] = useQueryState(
"page",
parseAsInteger.withOptions(queryStateOptions).withDefault(1)
@@ -127,13 +149,208 @@ export function useDataTable<TData>({
.withDefault(initialState?.pagination?.pageSize ?? 10)
)
- // -------- Sorting (sort) URL 동기화 --------
+ // pageSize 기반 무한 스크롤 모드 자동 결정
+ const isInfiniteMode = perPage >= INFINITE_SCROLL_THRESHOLD
+
+ // -------- Sorting URL 동기화 --------
const [sorting, setSorting] = useQueryState(
"sort",
getSortingStateParser<TData>()
.withOptions(queryStateOptions)
.withDefault(initialState?.sorting ?? [])
)
+
+ // -------- Advanced Filters URL 동기화 --------
+ const [filters, setFilters] = useQueryState(
+ "filters",
+ parseAsString.withOptions(queryStateOptions).withDefault("[]")
+ )
+
+ const [joinOperator, setJoinOperator] = useQueryState(
+ "joinOperator",
+ parseAsString.withOptions(queryStateOptions).withDefault("and")
+ )
+
+ const [search, setSearch] = useQueryState(
+ "search",
+ parseAsString.withOptions(queryStateOptions).withDefault("")
+ )
+
+ // -------- Grouping URL 동기화 --------
+ const [grouping, setGrouping] = useQueryState(
+ "group",
+ parseAsArrayOf(parseAsString, ",")
+ .withOptions(queryStateOptions)
+ .withDefault(initialState?.grouping ?? [])
+ )
+
+ const [expanded, setExpanded] = React.useState<ExpandedState>({})
+
+ // -------- 무한 스크롤 SWR 설정 --------
+ const parsedFilters = React.useMemo(() => {
+ try {
+ return JSON.parse(filters)
+ } catch {
+ return []
+ }
+ }, [filters])
+
+ const sortForSWR = React.useMemo(() => {
+ return sorting.map(sort => ({
+ id: sort.id,
+ desc: sort.desc
+ }))
+ }, [sorting])
+
+ // 실제 페이지 크기 계산 (무한 스크롤 시)
+ const effectivePageSize = React.useMemo(() => {
+ if (!isInfiniteMode) return perPage
+
+ // 무한 스크롤 모드에서는 적절한 청크 크기 사용
+ const maxSize = infiniteScrollConfig?.maxPageSize || 100
+ return Math.min(50, maxSize) // 기본 50개씩 로드
+ }, [isInfiniteMode, perPage, infiniteScrollConfig?.maxPageSize])
+
+ // SWR 키 생성 함수
+ const getKey = React.useCallback(
+ (pageIndex: number, previousPageData: InfiniteScrollResponse<TData> | null) => {
+ if (!isInfiniteMode || !infiniteScrollConfig) return null
+
+ const params = new URLSearchParams()
+
+ if (pageIndex === 0) {
+ // 첫 페이지
+ params.set("limit", String(effectivePageSize))
+ if (search) params.set("search", search)
+ if (parsedFilters.length) params.set("filters", JSON.stringify(parsedFilters))
+ if (joinOperator !== "and") params.set("joinOperator", joinOperator)
+ if (sortForSWR.length) params.set("sort", JSON.stringify(sortForSWR))
+
+ return `${infiniteScrollConfig.apiEndpoint}?${params.toString()}`
+ }
+
+ // 다음 페이지
+ if (!previousPageData || !previousPageData.hasNextPage) return null
+
+ params.set("cursor", previousPageData.nextCursor || "")
+ params.set("limit", String(effectivePageSize))
+ if (search) params.set("search", search)
+ if (parsedFilters.length) params.set("filters", JSON.stringify(parsedFilters))
+ if (joinOperator !== "and") params.set("joinOperator", joinOperator)
+ if (sortForSWR.length) params.set("sort", JSON.stringify(sortForSWR))
+
+ return `${infiniteScrollConfig.apiEndpoint}?${params.toString()}`
+ },
+ [isInfiniteMode, infiniteScrollConfig, effectivePageSize, search, parsedFilters, joinOperator, sortForSWR]
+ )
+
+ // SWR Infinite 사용
+ const {
+ data: swrData,
+ error: swrError,
+ isLoading: swrIsLoading,
+ isValidating: swrIsValidating,
+ mutate: swrMutate,
+ size: swrSize,
+ setSize: swrSetSize,
+ } = useSWRInfinite<InfiniteScrollResponse<TData>>(
+ getKey,
+ async (url: string) => {
+ const response = await fetch(url)
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
+ return response.json()
+ },
+ {
+ revalidateFirstPage: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: true,
+ }
+ )
+
+ // 무한 스크롤 데이터 병합
+ const infiniteData = React.useMemo(() => {
+ if (!isInfiniteMode || !swrData) return []
+ return swrData.flatMap(page => page.data)
+ }, [swrData, isInfiniteMode])
+
+ // 무한 스크롤 메타 정보
+ const infiniteMeta = React.useMemo(() => {
+ if (!isInfiniteMode || !infiniteScrollConfig || !swrData) return null
+
+ const totalCount = swrData[0]?.total ?? null
+ const hasNextPage = swrData[swrData.length - 1]?.hasNextPage ?? false
+ const isLoadingMore = swrIsValidating && swrData && typeof swrData[swrSize - 1] !== "undefined"
+
+ return {
+ enabled: true,
+ totalCount,
+ hasNextPage,
+ isLoadingMore,
+ loadMore: () => {
+ if (hasNextPage && !isLoadingMore) {
+ swrSetSize(prev => prev + 1)
+ }
+ },
+ reset: () => {
+ swrSetSize(1)
+ swrMutate()
+ },
+ refresh: () => swrMutate(),
+ error: swrError,
+ isLoading: swrIsLoading,
+ isEmpty: swrData?.[0]?.data.length === 0,
+ }
+ }, [isInfiniteMode, infiniteScrollConfig, swrData, swrIsValidating, swrSize, swrError, swrIsLoading, swrSetSize, swrMutate])
+
+ // 검색어나 필터 변경 시 무한 스크롤 리셋
+ React.useEffect(() => {
+ if (isInfiniteMode && infiniteMeta) {
+ infiniteMeta.reset()
+ }
+ }, [search, parsedFilters, joinOperator, sortForSWR, isInfiniteMode])
+
+ // 최종 데이터 결정
+ const finalData = isInfiniteMode ? infiniteData : initialData
+
+ // pageSize 변경 핸들러
+ const handlePageSizeChange = React.useCallback((newPageSize: number) => {
+ // URL 상태 업데이트 (이게 핵심!)
+ void setPerPage(newPageSize)
+
+ // 모드 전환 시 첫 페이지로 리셋
+ const wasInfiniteMode = perPage >= INFINITE_SCROLL_THRESHOLD
+ const willBeInfiniteMode = newPageSize >= INFINITE_SCROLL_THRESHOLD
+
+ if (wasInfiniteMode !== willBeInfiniteMode) {
+ void setPage(1)
+
+ // 무한 스크롤에서 페이지네이션으로 전환 시 무한 스크롤 데이터 리셋
+ if (wasInfiniteMode && infiniteMeta) {
+ infiniteMeta.reset()
+ }
+ }
+ }, [setPerPage, setPage, perPage, infiniteMeta])
+
+ // 그리고 onPaginationChange 함수도 수정
+ function onPaginationChange(updaterOrValue: Updater<PaginationState>) {
+ if (isInfiniteMode) return // 무한 스크롤 모드에서는 페이지네이션 변경 무시
+
+ if (typeof updaterOrValue === "function") {
+ const newPagination = updaterOrValue(pagination)
+ void setPage(newPagination.pageIndex + 1)
+ // perPage 변경은 handlePageSizeChange를 통해서만!
+ if (newPagination.pageSize !== perPage) {
+ handlePageSizeChange(newPagination.pageSize)
+ }
+ } else {
+ void setPage(updaterOrValue.pageIndex + 1)
+ // perPage 변경은 handlePageSizeChange를 통해서만!
+ if (updaterOrValue.pageSize !== perPage) {
+ handlePageSizeChange(updaterOrValue.pageSize)
+ }
+ }
+ }
+ // -------- 나머지 기존 로직들 --------
function onSortingChange(updaterOrValue: Updater<SortingState>) {
if (typeof updaterOrValue === "function") {
const newSorting = updaterOrValue(sorting) as ExtendedSortingState<TData>
@@ -143,13 +360,6 @@ export function useDataTable<TData>({
}
}
- // -------- Grouping (group) URL 동기화 (멀티 컬럼) --------
- const [grouping, setGrouping] = useQueryState(
- "group",
- parseAsArrayOf(parseAsString, ",")
- .withOptions(queryStateOptions)
- .withDefault(initialState?.grouping ?? [])
- )
function onGroupingChange(updaterOrValue: Updater<string[]>) {
if (typeof updaterOrValue === "function") {
const newGrouping = updaterOrValue(grouping)
@@ -159,25 +369,26 @@ export function useDataTable<TData>({
}
}
- // -------- Group Expand/Collapse --------
- const [expanded, setExpanded] = React.useState<ExpandedState>({}) // or true/false
-
function onExpandedChange(updater: Updater<ExpandedState>) {
setExpanded((old) => (typeof updater === "function" ? updater(old) : updater))
}
+
+ const pagination: PaginationState = {
+ pageIndex: page - 1,
+ pageSize: perPage,
+ }
- // -------- Filters (search/faceted) URL 동기화 --------
+
+ // 기존 필터 로직들... (동일)
const filterParsers = React.useMemo(() => {
return filterFields.reduce<
Record<string, Parser<string> | Parser<string[]>>
>((acc, field) => {
if (field.options) {
- // Faceted filter -> 여러 값 가능
acc[field.id] = parseAsArrayOf(parseAsString, ",").withOptions(
queryStateOptions
)
} else {
- // Search filter -> 단일 값
acc[field.id] = parseAsString.withOptions(queryStateOptions)
}
return acc
@@ -190,25 +401,7 @@ export function useDataTable<TData>({
debounceMs
)
- // -------- PaginationState 객체 --------
- const pagination: PaginationState = {
- pageIndex: page - 1,
- pageSize: perPage,
- }
- function onPaginationChange(updaterOrValue: Updater<PaginationState>) {
- if (typeof updaterOrValue === "function") {
- const newPagination = updaterOrValue(pagination)
- void setPage(newPagination.pageIndex + 1)
- void setPerPage(newPagination.pageSize)
- } else {
- void setPage(updaterOrValue.pageIndex + 1)
- void setPerPage(updaterOrValue.pageSize)
- }
- }
-
- // -------- ColumnFiltersState --------
const initialColumnFilters: ColumnFiltersState = React.useMemo(() => {
- // AdvancedFilter 모드가 아니면 URL에서 온 filterValues를 그대로 적용
return enableAdvancedFilter
? []
: Object.entries(filterValues).reduce<ColumnFiltersState>(
@@ -228,7 +421,6 @@ export function useDataTable<TData>({
const [columnFilters, setColumnFilters] =
React.useState<ColumnFiltersState>(initialColumnFilters)
- // 검색용 / Facet 필터용 컬럼 구분
const { searchableColumns, filterableColumns } = React.useMemo(() => {
return enableAdvancedFilter
? { searchableColumns: [], filterableColumns: [] }
@@ -258,14 +450,12 @@ export function useDataTable<TData>({
return acc
}, {})
- // 빠진 필터는 null로 설정
prev.forEach((prevFilter) => {
if (!next.some((filter) => filter.id === prevFilter.id)) {
filterUpdates[prevFilter.id] = null
}
})
- // 필터가 바뀌면 첫 페이지로
void setPage(1)
debouncedSetFilterValues(filterUpdates)
return next
@@ -283,8 +473,9 @@ export function useDataTable<TData>({
// -------- TanStack Table 인스턴스 생성 --------
const table = useReactTable({
...props,
+ data: finalData,
initialState,
- pageCount,
+ pageCount: isInfiniteMode ? -1 : pageCount,
state: {
pagination,
sorting,
@@ -295,7 +486,6 @@ export function useDataTable<TData>({
expanded,
},
- // 콜백들
onRowSelectionChange: setRowSelection,
onPaginationChange,
onSortingChange,
@@ -304,31 +494,38 @@ export function useDataTable<TData>({
onGroupingChange,
onExpandedChange,
- // 기본 모델
getCoreRowModel: getCoreRowModel(),
- // 필터 (Advanced 모드 아니면 사용)
getFilteredRowModel: enableAdvancedFilter ? undefined : getFilteredRowModel(),
- getPaginationRowModel: getPaginationRowModel(),
+ getPaginationRowModel: isInfiniteMode ? undefined : getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
- // 그룹 + 확장
getGroupedRowModel: getGroupedRowModel(),
getExpandedRowModel: getExpandedRowModel(),
- // Faceted (Facet 필터용)
getFacetedRowModel: enableAdvancedFilter ? undefined : getFacetedRowModel(),
- getFacetedUniqueValues: enableAdvancedFilter
- ? undefined
- : getFacetedUniqueValues(),
+ getFacetedUniqueValues: enableAdvancedFilter ? undefined : getFacetedUniqueValues(),
- // 서버 사이드 사용
manualPagination: true,
manualSorting: true,
manualFiltering: true,
- // 그룹핑도 서버에서 처리한다면 별도 로직이 필요하지만,
- // TanStack Table v8에는 manualGrouping 옵션은 없음
- // (그룹핑을 서버에서 이미 해서 내려받는다면 row 구조 처리 필요)
-
})
- return { table }
+ return {
+ table,
+ // 무한 스크롤 정보
+ infiniteScroll: infiniteMeta,
+ // 모드 정보
+ isInfiniteMode,
+ effectivePageSize,
+ // 페이지 크기 변경 핸들러
+ handlePageSizeChange,
+ // URL 상태 관리 함수들
+ urlState: {
+ search,
+ setSearch,
+ filters: parsedFilters,
+ setFilters: (newFilters: any[]) => setFilters(JSON.stringify(newFilters)),
+ joinOperator,
+ setJoinOperator,
+ }
+ }
} \ No newline at end of file