// ====================== 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"; import isEqual from "fast-deep-equal"; // ─────────────────────────────────────────────────────────────────────── // 무한 스크롤 관련 상수 및 타입 // ─────────────────────────────────────────────────────────────────────── const INFINITE_SCROLL_THRESHOLD = 1_000_000; interface InfiniteScrollConfig { apiEndpoint: string; tableName: string; maxPageSize?: number; } interface InfiniteScrollResponse { mode: "infinite"; data: TData[]; hasNextPage: boolean; nextCursor: string | null; total?: number | null; } // ─────────────────────────────────────────────────────────────────────── // Hook props // ─────────────────────────────────────────────────────────────────────── export interface UseDataTableProps extends Omit< TableOptions, | "state" | "pageCount" | "getCoreRowModel" | "manualFiltering" | "manualPagination" | "manualSorting" | "onGroupingChange" | "onExpandedChange" | "getExpandedRowModel" | "data" >, 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[]; // 페이지네이션 모드 초기 데이터 infiniteScrollConfig?: InfiniteScrollConfig; /** Table state가 변경되어 URL을 동기화할 때 호출됩니다. 이미 같은 문자열이면 호출되지 않습니다. */ onStateToUrl?: (queryString: string) => void; } // ─────────────────────────────────────────────────────────────────────── // useDataTable // ─────────────────────────────────────────────────────────────────────── 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, onStateToUrl, ...props }: UseDataTableProps) { // ───────────────────────── 공통 QueryState 옵션 ─────────────────────── const queryStateOptions = React.useMemo< Omit, "parse"> >( () => ({ 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 ?? {}); const [expanded, setExpanded] = React.useState({}); // ───────────────────────── URL ↔️ Pagination 동기화 ─────────────────── const [page, setPage] = useQueryState( "page", parseAsInteger.withOptions(queryStateOptions).withDefault(1), ); const [perPage, setPerPage] = useQueryState( "perPage", parseAsInteger .withOptions(queryStateOptions) .withDefault(initialState?.pagination?.pageSize ?? 10), ); const isInfiniteMode = !!( infiniteScrollConfig && perPage >= INFINITE_SCROLL_THRESHOLD ); // ───────────────────────── URL ↔️ Sorting 동기화 ───────────────────── const [sorting, setSorting] = useQueryState( "sort", getSortingStateParser() .withOptions(queryStateOptions) .withDefault(initialState?.sorting ?? []), ); // ───────────────────────── 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(""), ); const [grouping, setGrouping] = useQueryState( "group", parseAsArrayOf(parseAsString, ",") .withOptions(queryStateOptions) .withDefault(initialState?.grouping ?? []), ); // ───────────────────────── Pagination helper ───────────────────────── const pagination: PaginationState = { pageIndex: page - 1, pageSize: perPage, }; // ───────────────────────── Table → URL 직렬화 ──────────────────────── const toQueryString = React.useCallback( (pg: PaginationState, sort: SortingState) => { const p = new URLSearchParams(); if (pg.pageIndex > 0) p.set("page", String(pg.pageIndex + 1)); if (pg.pageSize !== perPage) p.set("perPage", String(pg.pageSize)); if (sort.length) p.set("sort", JSON.stringify(sort)); return p.toString(); }, [perPage], ); // ───────────────────────── 페이징 변경 ─────────────────────────────── const handlePageSizeChange = React.useCallback( (newPageSize: number) => { void setPerPage(newPageSize); const wasInfinite = perPage >= INFINITE_SCROLL_THRESHOLD; const willBeInfinite = newPageSize >= INFINITE_SCROLL_THRESHOLD; if (wasInfinite !== willBeInfinite) { void setPage(1); } }, [perPage, setPerPage, setPage], ); function onPaginationChange(updater: Updater) { if (isInfiniteMode) return; const next = typeof updater === "function" ? updater(pagination) : updater; // URL 동기화 (diff) if (onStateToUrl) { const qs = toQueryString(next, sorting); if (qs !== window.location.search.slice(1)) onStateToUrl(qs); } void setPage(next.pageIndex + 1); if (next.pageSize !== perPage) handlePageSizeChange(next.pageSize); } // ───────────────────────── Sorting 변경 ────────────────────────────── function onSortingChange(updater: Updater) { const next = typeof updater === "function" ? updater(sorting) : updater; if (onStateToUrl) { const qs = toQueryString(pagination, next); if (qs !== window.location.search.slice(1)) onStateToUrl(qs); } if (!isEqual(next, sorting)) { void setSorting(next as ExtendedSortingState); } } // -------- 무한 스크롤 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 키 생성 함수 - 안정화를 위해 useCallback 사용 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]) // 무한 스크롤 메타 정보 - 안정화를 위해 useCallback 사용 const infiniteScrollActions = React.useMemo(() => ({ loadMore: () => { if (swrData && swrData[swrData.length - 1]?.hasNextPage && !swrIsValidating) { swrSetSize(prev => prev + 1) } }, reset: () => { swrSetSize(1) swrMutate() }, refresh: () => swrMutate(), }), [swrData, swrIsValidating, swrSetSize, swrMutate]) const infiniteMeta = React.useMemo(() => { if (!isInfiniteMode || !infiniteScrollConfig) 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, onLoadMore: infiniteScrollActions.loadMore, reset: infiniteScrollActions.reset, refresh: infiniteScrollActions.refresh, error: swrError, isLoading: swrIsLoading, isEmpty: swrData?.[0]?.data.length === 0, } }, [ isInfiniteMode, infiniteScrollConfig, swrData, swrIsValidating, swrSize, swrError, swrIsLoading, infiniteScrollActions ]) // 검색어나 필터 변경 시 무한 스크롤 리셋 - infiniteMeta 의존성 제거 const resetInfiniteScroll = React.useCallback(() => { if (isInfiniteMode && infiniteScrollActions) { infiniteScrollActions.reset() } }, [isInfiniteMode, infiniteScrollActions]) // 필터 변경 시 리셋 - useEffect dependency 최소화 React.useEffect(() => { resetInfiniteScroll() }, [search, filters, joinOperator]) // 최종 데이터 결정 const finalData = isInfiniteMode ? infiniteData : initialData 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 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, } } }