// hooks/use-data-table.ts (업데이트) "use client" import * as React from "react" import type { DataTableFilterField, ExtendedSortingState } from "@/types/table" import { getCoreRowModel, getFacetedRowModel, getFacetedUniqueValues, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, getGroupedRowModel, getExpandedRowModel, useReactTable, type ColumnFiltersState, type PaginationState, type RowSelectionState, type SortingState, type TableOptions, type TableState, type Updater, type VisibilityState, type ExpandedState, } from "@tanstack/react-table" import { parseAsArrayOf, parseAsInteger, parseAsString, useQueryState, useQueryStates, 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 extends Omit< TableOptions, | "state" | "pageCount" | "getCoreRowModel" | "manualFiltering" | "manualPagination" | "manualSorting" | "onGroupingChange" | "onExpandedChange" | "getExpandedRowModel" >, Required, "pageCount">> { filterFields?: DataTableFilterField[] enableAdvancedFilter?: boolean history?: "push" | "replace" scroll?: boolean shallow?: boolean throttleMs?: number debounceMs?: number startTransition?: React.TransitionStartFunction clearOnDefault?: boolean initialState?: Omit, "sorting"> & { sorting?: ExtendedSortingState grouping?: string[] expanded?: Record } // 기존 데이터 (페이지네이션 모드용) data?: TData[] // 무한 스크롤 설정 (pageSize 기반 자동 활성화) infiniteScrollConfig?: InfiniteScrollConfig } // 무한 스크롤 응답 타입 interface InfiniteScrollResponse { mode: 'infinite' data: TData[] hasNextPage: boolean nextCursor: string | null total?: number | null } export function useDataTable({ pageCount = -1, filterFields = [], enableAdvancedFilter = false, history = "replace", scroll = false, shallow = true, throttleMs = 50, debounceMs = 300, clearOnDefault = false, startTransition, initialState, data: initialData = [], infiniteScrollConfig, ...props }: UseDataTableProps) { // 공통 URL QueryState 옵션 const queryStateOptions = React.useMemo< Omit, "parse"> >(() => { return { history, scroll, shallow, throttleMs, debounceMs, clearOnDefault, startTransition, } }, [ history, scroll, shallow, throttleMs, debounceMs, clearOnDefault, startTransition, ]) // -------- 기존 상태들 -------- const [rowSelection, setRowSelection] = React.useState( initialState?.rowSelection ?? {} ) const [columnVisibility, setColumnVisibility] = React.useState(initialState?.columnVisibility ?? {}) // -------- Pagination URL 동기화 -------- const [page, setPage] = useQueryState( "page", parseAsInteger.withOptions(queryStateOptions).withDefault(1) ) const [perPage, setPerPage] = useQueryState( "perPage", parseAsInteger .withOptions(queryStateOptions) .withDefault(initialState?.pagination?.pageSize ?? 10) ) // pageSize 기반 무한 스크롤 모드 자동 결정 const isInfiniteMode = perPage >= INFINITE_SCROLL_THRESHOLD // -------- Sorting URL 동기화 -------- const [sorting, setSorting] = useQueryState( "sort", getSortingStateParser() .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({}) // -------- 무한 스크롤 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 | 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>( 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) { 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) { if (typeof updaterOrValue === "function") { const newSorting = updaterOrValue(sorting) as ExtendedSortingState void setSorting(newSorting) } else { void setSorting(updaterOrValue as ExtendedSortingState) } } function onGroupingChange(updaterOrValue: Updater) { if (typeof updaterOrValue === "function") { const newGrouping = updaterOrValue(grouping) void setGrouping(newGrouping) } else { void setGrouping(updaterOrValue) } } function onExpandedChange(updater: Updater) { setExpanded((old) => (typeof updater === "function" ? updater(old) : updater)) } const pagination: PaginationState = { pageIndex: page - 1, pageSize: perPage, } // 기존 필터 로직들... (동일) const filterParsers = React.useMemo(() => { return filterFields.reduce< Record | Parser> >((acc, field) => { if (field.options) { acc[field.id] = parseAsArrayOf(parseAsString, ",").withOptions( queryStateOptions ) } else { acc[field.id] = parseAsString.withOptions(queryStateOptions) } return acc }, {}) }, [filterFields, queryStateOptions]) const [filterValues, setFilterValues] = useQueryStates(filterParsers) const debouncedSetFilterValues = useDebouncedCallback( setFilterValues, debounceMs ) const initialColumnFilters: ColumnFiltersState = React.useMemo(() => { return enableAdvancedFilter ? [] : Object.entries(filterValues).reduce( (filters, [key, value]) => { if (value !== null) { filters.push({ id: key, value: Array.isArray(value) ? value : [value], }) } return filters }, [] ) }, [filterValues, enableAdvancedFilter]) const [columnFilters, setColumnFilters] = React.useState(initialColumnFilters) const { searchableColumns, filterableColumns } = React.useMemo(() => { return enableAdvancedFilter ? { searchableColumns: [], filterableColumns: [] } : { searchableColumns: filterFields.filter((field) => !field.options), filterableColumns: filterFields.filter((field) => field.options), } }, [filterFields, enableAdvancedFilter]) const onColumnFiltersChange = React.useCallback( (updaterOrValue: Updater) => { if (enableAdvancedFilter) return setColumnFilters((prev) => { const next = typeof updaterOrValue === "function" ? updaterOrValue(prev) : updaterOrValue const filterUpdates = next.reduce< Record >((acc, filter) => { if (searchableColumns.find((col) => col.id === filter.id)) { acc[filter.id] = filter.value as string } else if (filterableColumns.find((col) => col.id === filter.id)) { acc[filter.id] = filter.value as string[] } return acc }, {}) prev.forEach((prevFilter) => { if (!next.some((filter) => filter.id === prevFilter.id)) { filterUpdates[prevFilter.id] = null } }) void setPage(1) debouncedSetFilterValues(filterUpdates) return next }) }, [ debouncedSetFilterValues, enableAdvancedFilter, filterableColumns, searchableColumns, setPage, ] ) // -------- TanStack Table 인스턴스 생성 -------- const table = useReactTable({ ...props, data: finalData, initialState, pageCount: isInfiniteMode ? -1 : pageCount, state: { pagination, sorting, columnVisibility, rowSelection, columnFilters: enableAdvancedFilter ? [] : columnFilters, grouping, expanded, }, onRowSelectionChange: setRowSelection, onPaginationChange, onSortingChange, onColumnFiltersChange, onColumnVisibilityChange: setColumnVisibility, onGroupingChange, onExpandedChange, getCoreRowModel: getCoreRowModel(), getFilteredRowModel: enableAdvancedFilter ? undefined : getFilteredRowModel(), getPaginationRowModel: isInfiniteMode ? undefined : getPaginationRowModel(), getSortedRowModel: getSortedRowModel(), getGroupedRowModel: getGroupedRowModel(), getExpandedRowModel: getExpandedRowModel(), getFacetedRowModel: enableAdvancedFilter ? undefined : getFacetedRowModel(), getFacetedUniqueValues: enableAdvancedFilter ? undefined : getFacetedUniqueValues(), manualPagination: true, manualSorting: true, manualFiltering: true, }) return { table, // 무한 스크롤 정보 infiniteScroll: infiniteMeta, // 모드 정보 isInfiniteMode, effectivePageSize, // 페이지 크기 변경 핸들러 handlePageSizeChange, // URL 상태 관리 함수들 urlState: { search, setSearch, filters: parsedFilters, setFilters: (newFilters: any[]) => setFilters(JSON.stringify(newFilters)), joinOperator, setJoinOperator, } } }