import { type ColumnFiltersState, getCoreRowModel, getFacetedMinMaxValues, getFacetedRowModel, getFacetedUniqueValues, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, type PaginationState, type RowSelectionState, type SortingState, type TableOptions, type TableState, type Updater, useReactTable, type VisibilityState, } from "@tanstack/react-table"; import { parseAsArrayOf, parseAsInteger, parseAsString, type SingleParser, type UseQueryStateOptions, useQueryState, useQueryStates, } from "nuqs"; import * as React from "react"; import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; import { getSortingStateParser } from "@/lib/parsers"; import type { ExtendedColumnSort, QueryKeys } from "@/types/data-table"; const PAGE_KEY = "page"; const PER_PAGE_KEY = "perPage"; const SORT_KEY = "sort"; const FILTERS_KEY = "filters"; const JOIN_OPERATOR_KEY = "joinOperator"; const ARRAY_SEPARATOR = ","; const DEBOUNCE_MS = 300; const THROTTLE_MS = 50; interface UseDataTableProps extends Omit< TableOptions, | "state" | "pageCount" | "getCoreRowModel" | "manualFiltering" | "manualPagination" | "manualSorting" >, Required, "pageCount">> { initialState?: Omit, "sorting"> & { sorting?: ExtendedColumnSort[]; }; queryKeys?: Partial; history?: "push" | "replace"; debounceMs?: number; throttleMs?: number; clearOnDefault?: boolean; enableAdvancedFilter?: boolean; scroll?: boolean; shallow?: boolean; startTransition?: React.TransitionStartFunction; } export function useDataTable(props: UseDataTableProps) { const { columns, pageCount = -1, initialState, queryKeys, history = "replace", debounceMs = DEBOUNCE_MS, throttleMs = THROTTLE_MS, clearOnDefault = false, enableAdvancedFilter = false, scroll = false, shallow = true, startTransition, ...tableProps } = props; const pageKey = queryKeys?.page ?? PAGE_KEY; const perPageKey = queryKeys?.perPage ?? PER_PAGE_KEY; const sortKey = queryKeys?.sort ?? SORT_KEY; const filtersKey = queryKeys?.filters ?? FILTERS_KEY; const joinOperatorKey = queryKeys?.joinOperator ?? JOIN_OPERATOR_KEY; 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 [page, setPage] = useQueryState( pageKey, parseAsInteger.withOptions(queryStateOptions).withDefault(1), ); const [perPage, setPerPage] = useQueryState( perPageKey, parseAsInteger .withOptions(queryStateOptions) .withDefault(initialState?.pagination?.pageSize ?? 10), ); const pagination: PaginationState = React.useMemo(() => { return { pageIndex: page - 1, // zero-based index -> one-based index pageSize: perPage, }; }, [page, perPage]); const onPaginationChange = React.useCallback( (updaterOrValue: Updater) => { 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); } }, [pagination, setPage, setPerPage], ); const columnIds = React.useMemo(() => { return new Set( columns.map((column) => column.id).filter(Boolean) as string[], ); }, [columns]); const [sorting, setSorting] = useQueryState( sortKey, getSortingStateParser(columnIds) .withOptions(queryStateOptions) .withDefault(initialState?.sorting ?? []), ); const onSortingChange = React.useCallback( (updaterOrValue: Updater) => { if (typeof updaterOrValue === "function") { const newSorting = updaterOrValue(sorting); setSorting(newSorting as ExtendedColumnSort[]); } else { setSorting(updaterOrValue as ExtendedColumnSort[]); } }, [sorting, setSorting], ); const filterableColumns = React.useMemo(() => { if (enableAdvancedFilter) return []; return columns.filter((column) => column.enableColumnFilter); }, [columns, enableAdvancedFilter]); const filterParsers = React.useMemo(() => { if (enableAdvancedFilter) return {}; return filterableColumns.reduce< Record | SingleParser> >((acc, column) => { if (column.meta?.options) { acc[column.id ?? ""] = parseAsArrayOf( parseAsString, ARRAY_SEPARATOR, ).withOptions(queryStateOptions); } else { acc[column.id ?? ""] = parseAsString.withOptions(queryStateOptions); } return acc; }, {}); }, [filterableColumns, queryStateOptions, enableAdvancedFilter]); const [filterValues, setFilterValues] = useQueryStates(filterParsers); const debouncedSetFilterValues = useDebouncedCallback( (values: typeof filterValues) => { void setPage(1); void setFilterValues(values); }, debounceMs, ); const initialColumnFilters: ColumnFiltersState = React.useMemo(() => { if (enableAdvancedFilter) return []; return Object.entries(filterValues).reduce( (filters, [key, value]) => { if (value !== null) { const processedValue = Array.isArray(value) ? value : typeof value === "string" && /[^a-zA-Z0-9]/.test(value) ? value.split(/[^a-zA-Z0-9]+/).filter(Boolean) : [value]; filters.push({ id: key, value: processedValue, }); } return filters; }, [], ); }, [filterValues, enableAdvancedFilter]); const [columnFilters, setColumnFilters] = React.useState(initialColumnFilters); 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 (filterableColumns.find((column) => column.id === filter.id)) { acc[filter.id] = filter.value as string | string[]; } return acc; }, {}); for (const prevFilter of prev) { if (!next.some((filter) => filter.id === prevFilter.id)) { filterUpdates[prevFilter.id] = null; } } debouncedSetFilterValues(filterUpdates); return next; }); }, [debouncedSetFilterValues, filterableColumns, enableAdvancedFilter], ); const table = useReactTable({ ...tableProps, columns, initialState, pageCount, state: { pagination, sorting, columnVisibility, rowSelection, columnFilters, }, defaultColumn: { ...tableProps.defaultColumn, enableColumnFilter: false, }, enableRowSelection: true, onRowSelectionChange: setRowSelection, onPaginationChange, onSortingChange, onColumnFiltersChange, onColumnVisibilityChange: setColumnVisibility, getCoreRowModel: getCoreRowModel(), getFilteredRowModel: getFilteredRowModel(), getPaginationRowModel: getPaginationRowModel(), getSortedRowModel: getSortedRowModel(), getFacetedRowModel: getFacetedRowModel(), getFacetedUniqueValues: getFacetedUniqueValues(), getFacetedMinMaxValues: getFacetedMinMaxValues(), manualPagination: true, manualSorting: true, manualFiltering: true, meta: { ...tableProps.meta, queryKeys: { page: pageKey, perPage: perPageKey, sort: sortKey, filters: filtersKey, joinOperator: joinOperatorKey, }, }, }); return React.useMemo( () => ({ table, shallow, debounceMs, throttleMs }), [table, shallow, debounceMs, throttleMs], ); }