diff options
Diffstat (limited to 'hooks')
| -rw-r--r-- | hooks/use-data-table.ts | 358 |
1 files changed, 206 insertions, 152 deletions
diff --git a/hooks/use-data-table.ts b/hooks/use-data-table.ts index 1cbae9de..5cbf949f 100644 --- a/hooks/use-data-table.ts +++ b/hooks/use-data-table.ts @@ -1,4 +1,3 @@ -// ====================== hooks/use-data-table.ts ====================== "use client"; import * as React from "react"; @@ -93,9 +92,8 @@ export interface UseDataTableProps<TData> grouping?: string[]; expanded?: Record<string, boolean>; }; - data?: TData[]; // 페이지네이션 모드 초기 데이터 + data?: TData[]; infiniteScrollConfig?: InfiniteScrollConfig; - /** Table state가 변경되어 URL을 동기화할 때 호출됩니다. 이미 같은 문자열이면 호출되지 않습니다. */ onStateToUrl?: (queryString: string) => void; } @@ -119,52 +117,70 @@ export function useDataTable<TData>({ onStateToUrl, ...props }: UseDataTableProps<TData>) { - // ───────────────────────── 공통 QueryState 옵션 ─────────────────────── + + // ✅ 수정 1: queryStateOptions를 안정적으로 메모이제이션 const queryStateOptions = React.useMemo< Omit<UseQueryStateOptions<string>, "parse"> - >( - () => ({ - history, - scroll, - shallow, - throttleMs, - debounceMs, - clearOnDefault, - startTransition, - }), - [history, scroll, shallow, throttleMs, debounceMs, clearOnDefault, startTransition], - ); - - // ───────────────────────── 로컬 상태들 ──────────────────────────────── + >(() => ({ + history, + scroll, + shallow, + throttleMs, + debounceMs, + clearOnDefault, + startTransition, + }), [ + history, + scroll, + shallow, + throttleMs, + debounceMs, + clearOnDefault, + // startTransition 제거 - 함수 참조가 불안정할 수 있음 + ]); + + // ✅ 수정 2: 초기 상태들을 안정적으로 처리 + const stableInitialState = React.useMemo(() => initialState || {}, [initialState]); + const [rowSelection, setRowSelection] = React.useState<RowSelectionState>( - initialState?.rowSelection ?? {}, + () => stableInitialState?.rowSelection ?? {} + ); + const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>( + () => stableInitialState?.columnVisibility ?? {} ); - const [columnVisibility, setColumnVisibility] = - React.useState<VisibilityState>(initialState?.columnVisibility ?? {}); const [expanded, setExpanded] = React.useState<ExpandedState>({}); // ───────────────────────── URL ↔️ Pagination 동기화 ─────────────────── + const defaultPageSize = React.useMemo(() => + stableInitialState?.pagination?.pageSize ?? 10, + [stableInitialState?.pagination?.pageSize] + ); + const [page, setPage] = useQueryState( "page", parseAsInteger.withOptions(queryStateOptions).withDefault(1), ); const [perPage, setPerPage] = useQueryState( "perPage", - parseAsInteger - .withOptions(queryStateOptions) - .withDefault(initialState?.pagination?.pageSize ?? 10), + parseAsInteger.withOptions(queryStateOptions).withDefault(defaultPageSize), ); - const isInfiniteMode = !!( - infiniteScrollConfig && perPage >= INFINITE_SCROLL_THRESHOLD + const isInfiniteMode = React.useMemo(() => + !!(infiniteScrollConfig && perPage >= INFINITE_SCROLL_THRESHOLD), + [infiniteScrollConfig, perPage] ); // ───────────────────────── URL ↔️ Sorting 동기화 ───────────────────── + const defaultSorting = React.useMemo(() => + stableInitialState?.sorting ?? [], + [stableInitialState?.sorting] + ); + const [sorting, setSorting] = useQueryState( "sort", getSortingStateParser<TData>() .withOptions(queryStateOptions) - .withDefault(initialState?.sorting ?? []), + .withDefault(defaultSorting), ); // ───────────────────────── URL ↔️ 기타 파라미터 ────────────────────── @@ -180,32 +196,38 @@ export function useDataTable<TData>({ "search", parseAsString.withOptions(queryStateOptions).withDefault(""), ); + + const defaultGrouping = React.useMemo(() => + stableInitialState?.grouping ?? [], + [stableInitialState?.grouping] + ); + const [grouping, setGrouping] = useQueryState( "group", parseAsArrayOf(parseAsString, ",") .withOptions(queryStateOptions) - .withDefault(initialState?.grouping ?? []), + .withDefault(defaultGrouping), ); // ───────────────────────── Pagination helper ───────────────────────── - const pagination: PaginationState = { + const pagination: PaginationState = React.useMemo(() => ({ pageIndex: page - 1, pageSize: perPage, - }; + }), [page, perPage]); - // ───────────────────────── Table → URL 직렬화 ──────────────────────── + // ✅ 수정 3: toQueryString을 안정적으로 메모이제이션 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 (pg.pageSize !== defaultPageSize) p.set("perPage", String(pg.pageSize)); if (sort.length) p.set("sort", JSON.stringify(sort)); return p.toString(); }, - [perPage], + [defaultPageSize], // perPage 대신 defaultPageSize 사용 ); - // ───────────────────────── 페이징 변경 ─────────────────────────────── + // ✅ 수정 4: 페이징 변경 핸들러 최적화 const handlePageSizeChange = React.useCallback( (newPageSize: number) => { void setPerPage(newPageSize); @@ -218,11 +240,10 @@ export function useDataTable<TData>({ [perPage, setPerPage, setPage], ); - function onPaginationChange(updater: Updater<PaginationState>) { + const onPaginationChange = React.useCallback((updater: Updater<PaginationState>) => { 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); @@ -230,10 +251,10 @@ export function useDataTable<TData>({ void setPage(next.pageIndex + 1); if (next.pageSize !== perPage) handlePageSizeChange(next.pageSize); - } + }, [isInfiniteMode, pagination, sorting, toQueryString, onStateToUrl, setPage, perPage, handlePageSizeChange]); // ───────────────────────── Sorting 변경 ────────────────────────────── - function onSortingChange(updater: Updater<SortingState>) { + const onSortingChange = React.useCallback((updater: Updater<SortingState>) => { const next = typeof updater === "function" ? updater(sorting) : updater; if (onStateToUrl) { @@ -244,65 +265,79 @@ export function useDataTable<TData>({ if (!isEqual(next, sorting)) { void setSorting(next as ExtendedSortingState<TData>); } - } + }, [sorting, pagination, toQueryString, onStateToUrl, setSorting]); - // -------- 무한 스크롤 SWR 설정 -------- + // ✅ 수정 5: SWR 관련 값들을 안정적으로 메모이제이션 const parsedFilters = React.useMemo(() => { try { return JSON.parse(filters) } catch { return [] } - }, [filters]) + }, [filters]); - const sortForSWR = React.useMemo(() => { - return sorting.map(sort => ({ + const sortForSWR = React.useMemo(() => + sorting.map(sort => ({ id: sort.id, desc: sort.desc - })) - }, [sorting]) + })), + [sorting] + ); - // 실제 페이지 크기 계산 (무한 스크롤 시) const effectivePageSize = React.useMemo(() => { if (!isInfiniteMode) return perPage - - // 무한 스크롤 모드에서는 적절한 청크 크기 사용 const maxSize = infiniteScrollConfig?.maxPageSize || 100 - return Math.min(50, maxSize) // 기본 50개씩 로드 - }, [isInfiniteMode, perPage, infiniteScrollConfig?.maxPageSize]) + return Math.min(50, maxSize) + }, [isInfiniteMode, perPage, infiniteScrollConfig?.maxPageSize]); + + // ✅ 수정 6: getKey 함수의 의존성 최적화 + const swrKeyParams = React.useMemo(() => ({ + endpoint: infiniteScrollConfig?.apiEndpoint, + pageSize: effectivePageSize, + search: search || undefined, + filters: parsedFilters.length ? JSON.stringify(parsedFilters) : undefined, + joinOperator: joinOperator !== "and" ? joinOperator : undefined, + sort: sortForSWR.length ? JSON.stringify(sortForSWR) : undefined, + }), [ + infiniteScrollConfig?.apiEndpoint, + effectivePageSize, + search, + parsedFilters.length, + parsedFilters, + joinOperator, + sortForSWR.length, + sortForSWR + ]); - // SWR 키 생성 함수 - 안정화를 위해 useCallback 사용 const getKey = React.useCallback( (pageIndex: number, previousPageData: InfiniteScrollResponse<TData> | null) => { - if (!isInfiniteMode || !infiniteScrollConfig) return null + if (!isInfiniteMode || !swrKeyParams.endpoint) 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)) + params.set("limit", String(swrKeyParams.pageSize)) + if (swrKeyParams.search) params.set("search", swrKeyParams.search) + if (swrKeyParams.filters) params.set("filters", swrKeyParams.filters) + if (swrKeyParams.joinOperator) params.set("joinOperator", swrKeyParams.joinOperator) + if (swrKeyParams.sort) params.set("sort", swrKeyParams.sort) - return `${infiniteScrollConfig.apiEndpoint}?${params.toString()}` + return `${swrKeyParams.endpoint}?${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)) + params.set("limit", String(swrKeyParams.pageSize)) + if (swrKeyParams.search) params.set("search", swrKeyParams.search) + if (swrKeyParams.filters) params.set("filters", swrKeyParams.filters) + if (swrKeyParams.joinOperator) params.set("joinOperator", swrKeyParams.joinOperator) + if (swrKeyParams.sort) params.set("sort", swrKeyParams.sort) - return `${infiniteScrollConfig.apiEndpoint}?${params.toString()}` + return `${swrKeyParams.endpoint}?${params.toString()}` }, - [isInfiniteMode, infiniteScrollConfig, effectivePageSize, search, parsedFilters, joinOperator, sortForSWR] - ) + [isInfiniteMode, swrKeyParams] + ); // SWR Infinite 사용 const { @@ -325,15 +360,14 @@ export function useDataTable<TData>({ revalidateOnFocus: false, revalidateOnReconnect: true, } - ) + ); - // 무한 스크롤 데이터 병합 + // ✅ 수정 7: 무한 스크롤 데이터와 액션들을 안정적으로 메모이제이션 const infiniteData = React.useMemo(() => { if (!isInfiniteMode || !swrData) return [] return swrData.flatMap(page => page.data) - }, [swrData, isInfiniteMode]) + }, [swrData, isInfiniteMode]); - // 무한 스크롤 메타 정보 - 안정화를 위해 useCallback 사용 const infiniteScrollActions = React.useMemo(() => ({ loadMore: () => { if (swrData && swrData[swrData.length - 1]?.hasNextPage && !swrIsValidating) { @@ -345,7 +379,7 @@ export function useDataTable<TData>({ swrMutate() }, refresh: () => swrMutate(), - }), [swrData, swrIsValidating, swrSetSize, swrMutate]) + }), [swrData, swrIsValidating, swrSetSize, swrMutate]); const infiniteMeta = React.useMemo(() => { if (!isInfiniteMode || !infiniteScrollConfig) return null @@ -375,39 +409,45 @@ export function useDataTable<TData>({ swrError, swrIsLoading, infiniteScrollActions - ]) + ]); - // 검색어나 필터 변경 시 무한 스크롤 리셋 - infiniteMeta 의존성 제거 - const resetInfiniteScroll = React.useCallback(() => { + // ✅ 수정 8: 리셋 함수 최적화 + const resetInfiniteScrollRef = React.useRef<() => void>() + resetInfiniteScrollRef.current = () => { if (isInfiniteMode && infiniteScrollActions) { infiniteScrollActions.reset() } - }, [isInfiniteMode, infiniteScrollActions]) + } + + const resetInfiniteScroll = React.useCallback(() => { + resetInfiniteScrollRef.current?.() + }, []) - // 필터 변경 시 리셋 - useEffect dependency 최소화 + // 필터 변경 시 리셋 React.useEffect(() => { resetInfiniteScroll() - }, [search, filters, joinOperator]) + }, [search, filters, joinOperator, resetInfiniteScroll]); // 최종 데이터 결정 - const finalData = isInfiniteMode ? infiniteData : initialData + const finalData = React.useMemo(() => + isInfiniteMode ? infiniteData : initialData, + [isInfiniteMode, infiniteData, initialData] + ); - function onGroupingChange(updaterOrValue: Updater<string[]>) { + const onGroupingChange = React.useCallback((updaterOrValue: Updater<string[]>) => { if (typeof updaterOrValue === "function") { const newGrouping = updaterOrValue(grouping) void setGrouping(newGrouping) } else { void setGrouping(updaterOrValue) } - } + }, [grouping, setGrouping]); - function onExpandedChange(updater: Updater<ExpandedState>) { + const onExpandedChange = React.useCallback((updater: Updater<ExpandedState>) => { setExpanded((old) => (typeof updater === "function" ? updater(old) : updater)) - } + }, []); - - - // 기존 필터 로직들... (동일) + // ✅ 수정 9: filterParsers 안정화 const filterParsers = React.useMemo(() => { return filterFields.reduce< Record<string, Parser<string> | Parser<string[]>> @@ -421,7 +461,7 @@ export function useDataTable<TData>({ } return acc }, {}) - }, [filterFields, queryStateOptions]) + }, [filterFields, queryStateOptions]); const [filterValues, setFilterValues] = useQueryStates(filterParsers) const debouncedSetFilterValues = useDebouncedCallback( @@ -458,62 +498,70 @@ export function useDataTable<TData>({ } }, [filterFields, enableAdvancedFilter]) -// -------- column-filters 변경 핸들러 (루프 차단 버전) -------- -const onColumnFiltersChange = React.useCallback( - (updater: Updater<ColumnFiltersState>) => { - setColumnFilters(prev => { - const next = - typeof updater === "function" ? updater(prev) : updater - - /* 변동이 없으면 바로 종료 */ - if (deepEqual(prev, next)) return prev - - /* ---------- URL 동기화: 고급필터 OFF 때만 ---------- */ - if (!enableAdvancedFilter) { - const updates: Record<string, string | string[] | null> = {} - - next.forEach(f => { - if (searchableColumns.some(c => c.id === f.id)) - updates[f.id] = f.value as string - else if (filterableColumns.some(c => c.id === f.id)) - updates[f.id] = f.value as string[] - }) - prev.forEach(pf => { - if (!next.some(nf => nf.id === pf.id)) updates[pf.id] = null - }) - - void setPage(1) - debouncedSetFilterValues(updates) - } + // ✅ 수정 10: onColumnFiltersChange 최적화 (deepEqual 의존성 제거) + const onColumnFiltersChange = React.useCallback( + (updater: Updater<ColumnFiltersState>) => { + setColumnFilters(prev => { + const next = typeof updater === "function" ? updater(prev) : updater + + // deepEqual을 직접 호출 (의존성에서 제거) + if (deepEqual(prev, next)) return prev + + if (!enableAdvancedFilter) { + const updates: Record<string, string | string[] | null> = {} + + next.forEach(f => { + if (searchableColumns.some(c => c.id === f.id)) + updates[f.id] = f.value as string + else if (filterableColumns.some(c => c.id === f.id)) + updates[f.id] = f.value as string[] + }) + prev.forEach(pf => { + if (!next.some(nf => nf.id === pf.id)) updates[pf.id] = null + }) + + void setPage(1) + debouncedSetFilterValues(updates) + } - return next // ★ 항상 state 를 반영 - }) - }, - [ + return next + }) + }, + [ + enableAdvancedFilter, + searchableColumns, + filterableColumns, + debouncedSetFilterValues, + setPage, + ] + ) + + // ✅ 수정 11: 테이블 설정을 안정적으로 메모이제이션 + const tableState = React.useMemo(() => ({ + pagination, + sorting, + columnVisibility, + rowSelection, + columnFilters: enableAdvancedFilter ? [] : columnFilters, + grouping, + expanded, + }), [ + pagination, + sorting, + columnVisibility, + rowSelection, enableAdvancedFilter, - searchableColumns, - filterableColumns, - deepEqual, // fast-deep-equal - debouncedSetFilterValues, - setPage, - ] -) - - // -------- TanStack Table 인스턴스 생성 -------- + columnFilters, + grouping, + expanded, + ]) + const table = useReactTable({ ...props, data: finalData, - initialState, + initialState: stableInitialState, pageCount: isInfiniteMode ? -1 : pageCount, - state: { - pagination, - sorting, - columnVisibility, - rowSelection, - columnFilters: enableAdvancedFilter ? [] : columnFilters, - grouping, - expanded, - }, + state: tableState, onRowSelectionChange: setRowSelection, onPaginationChange, @@ -538,23 +586,29 @@ const onColumnFiltersChange = React.useCallback( manualFiltering: true, }) - return { + // ✅ 수정 12: 반환값 안정화 + const urlState = React.useMemo(() => ({ + search, + setSearch, + filters: parsedFilters, + setFilters: (newFilters: any[]) => setFilters(JSON.stringify(newFilters)), + joinOperator, + setJoinOperator, + }), [search, setSearch, parsedFilters, setFilters, joinOperator, setJoinOperator]) + + return React.useMemo(() => ({ table, - // 무한 스크롤 정보 infiniteScroll: infiniteMeta, - // 모드 정보 isInfiniteMode, effectivePageSize, - // 페이지 크기 변경 핸들러 handlePageSizeChange, - // URL 상태 관리 함수들 - urlState: { - search, - setSearch, - filters: parsedFilters, - setFilters: (newFilters: any[]) => setFilters(JSON.stringify(newFilters)), - joinOperator, - setJoinOperator, - } - } + urlState, + }), [ + table, + infiniteMeta, + isInfiniteMode, + effectivePageSize, + handlePageSizeChange, + urlState, + ]) }
\ No newline at end of file |
