Last active
December 11, 2025 08:45
-
-
Save devhammed/533562a87cba0eda6348fdad3d2e32a2 to your computer and use it in GitHub Desktop.
ShadCN UI Data Table with pagination (server, client, infinite), row selection, column visibility, column filtering (server, client), column sorting (server, client).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from '@/components/ui/table'; | |
| import { useControlledState } from '@/hooks/use-controlled-state'; | |
| import { cn } from '@/lib/utils'; | |
| import { InfiniteScroll } from '@inertiajs/react'; | |
| import { | |
| ColumnDef, | |
| ColumnFiltersState, | |
| flexRender, | |
| getCoreRowModel, | |
| getFilteredRowModel, | |
| getPaginationRowModel, | |
| getSortedRowModel, | |
| PaginationState, | |
| Row, | |
| RowSelectionState, | |
| SortingState, | |
| TableMeta, | |
| useReactTable, | |
| VisibilityState, | |
| } from '@tanstack/react-table'; | |
| import { Dispatch, SetStateAction, useRef, useState, type MouseEvent } from 'react'; | |
| import { useTranslation } from 'react-i18next'; | |
| export interface DataTableProps<TData, TValue> { | |
| columns: ColumnDef<TData, TValue>[]; | |
| data: TData[]; | |
| className?: string; | |
| meta?: TableMeta<TData>; | |
| sortingOptions?: { | |
| mode: 'server' | 'client'; | |
| state?: SortingState; | |
| initialState?: SortingState; | |
| onChange?: (sorting: SortingState) => void; | |
| }; | |
| filteringOptions?: { | |
| mode: 'server' | 'client'; | |
| state?: ColumnFiltersState; | |
| initialState?: ColumnFiltersState; | |
| onChange?: (filters: ColumnFiltersState) => void; | |
| }; | |
| visibilityOptions?: { | |
| state?: VisibilityState; | |
| initialState?: VisibilityState; | |
| onChange?: (visibility: VisibilityState) => void; | |
| }; | |
| rowSelectionOptions?: { | |
| state?: RowSelectionState; | |
| initialState?: RowSelectionState; | |
| enableMultiRowSelection?: boolean | ((row: Row<TData>) => boolean); | |
| enableSubRowSelection?: boolean | ((row: Row<TData>) => boolean); | |
| enableRowSelection?: boolean | ((row: Row<TData>) => boolean); | |
| onChange?: (selection: RowSelectionState) => void; | |
| onClick?: (row: Row<TData>, event: MouseEvent<HTMLTableRowElement>) => Promise<void> | void; | |
| onError?: (error: unknown, event: MouseEvent<HTMLTableRowElement>) => void; | |
| getRowId?: (originalRow: TData, index: number, parent?: Row<TData>) => string; | |
| }; | |
| paginationOptions?: | |
| | { | |
| mode: 'infinite'; | |
| data: string; | |
| as?: string; | |
| preserveUrl?: boolean; | |
| reverse?: boolean; | |
| autoScroll?: boolean; | |
| onlyNext?: boolean; | |
| onlyPrevious?: boolean; | |
| } | |
| | { | |
| mode: 'server'; | |
| pageCount?: number; | |
| rowCount?: number; | |
| state?: PaginationState; | |
| initialState?: PaginationState; | |
| onChange?: (pagination: PaginationState) => void; | |
| } | |
| | { | |
| mode: 'client'; | |
| state?: PaginationState; | |
| initialState?: PaginationState; | |
| onChange?: (pagination: PaginationState) => void; | |
| }; | |
| } | |
| export function DataTable<TData, TValue>({ | |
| columns, | |
| data, | |
| meta, | |
| className, | |
| sortingOptions, | |
| filteringOptions, | |
| visibilityOptions, | |
| rowSelectionOptions, | |
| paginationOptions, | |
| }: DataTableProps<TData, TValue>) { | |
| const { t } = useTranslation(['pagination']); | |
| const tableBodyRef = useRef<HTMLTableSectionElement | null>(null); | |
| const tableHeaderRef = useRef<HTMLTableSectionElement | null>(null); | |
| const tableFooterRef = useRef<HTMLTableSectionElement | null>(null); | |
| const [loadingId, setLoadingId] = useState<string | number | null>(null); | |
| const [sorting, onSortingChange] = useControlledState( | |
| sortingOptions?.initialState ?? [], | |
| sortingOptions?.state, | |
| sortingOptions?.onChange, | |
| ); | |
| const [columnFilters, onColumnFiltersChange] = useControlledState( | |
| filteringOptions?.initialState ?? [], | |
| filteringOptions?.state, | |
| filteringOptions?.onChange, | |
| ); | |
| const [columnVisibility, onColumnVisibilityChange] = useControlledState( | |
| visibilityOptions?.initialState ?? {}, | |
| visibilityOptions?.state, | |
| visibilityOptions?.onChange, | |
| ); | |
| const [rowSelection, onRowSelectionChange] = useControlledState( | |
| rowSelectionOptions?.initialState ?? {}, | |
| rowSelectionOptions?.state, | |
| rowSelectionOptions?.onChange, | |
| ); | |
| const [pagination, onPaginationChange] = useControlledState( | |
| paginationOptions?.mode !== 'infinite' && paginationOptions?.initialState !== undefined | |
| ? paginationOptions.initialState | |
| : { pageSize: 10, pageIndex: 0 }, | |
| paginationOptions?.mode !== 'infinite' ? paginationOptions?.state : undefined, | |
| paginationOptions?.mode !== 'infinite' ? paginationOptions?.onChange : undefined, | |
| ); | |
| const table = useReactTable({ | |
| data, | |
| meta, | |
| columns, | |
| onSortingChange, | |
| onColumnFiltersChange, | |
| onColumnVisibilityChange, | |
| onRowSelectionChange, | |
| onPaginationChange, | |
| getCoreRowModel: getCoreRowModel(), | |
| getPaginationRowModel: getPaginationRowModel(), | |
| getSortedRowModel: getSortedRowModel(), | |
| getFilteredRowModel: getFilteredRowModel(), | |
| manualPagination: paginationOptions?.mode !== 'client', | |
| manualSorting: sortingOptions?.mode === 'server', | |
| manualFiltering: filteringOptions?.mode === 'server', | |
| enableMultiRowSelection: rowSelectionOptions?.enableMultiRowSelection, | |
| enableSubRowSelection: rowSelectionOptions?.enableSubRowSelection, | |
| enableRowSelection: rowSelectionOptions?.enableRowSelection, | |
| getRowId: rowSelectionOptions?.getRowId, | |
| pageCount: paginationOptions?.mode === 'server' ? paginationOptions?.pageCount : undefined, | |
| rowCount: paginationOptions?.mode === 'server' ? paginationOptions?.rowCount : undefined, | |
| state: { | |
| sorting, | |
| columnFilters, | |
| columnVisibility, | |
| rowSelection, | |
| pagination, | |
| }, | |
| }); | |
| const children = ( | |
| <Table className={cn('border-separate border-spacing-y-2 sm:border-spacing-y-2.5', className)}> | |
| <TableHeader ref={tableHeaderRef}> | |
| {table.getHeaderGroups().map((headerGroup) => ( | |
| <TableRow | |
| key={headerGroup.id} | |
| className={cn('bg-transparent hover:bg-transparent', meta?.headerRowClassName)} | |
| > | |
| {headerGroup.headers.map((header) => { | |
| return ( | |
| <TableHead | |
| key={header.id} | |
| colSpan={header.colSpan} | |
| rowSpan={header.rowSpan} | |
| className={cn( | |
| 'border-b px-2.5 py-1.5 sm:py-2.5', | |
| meta?.headerClassName, | |
| header.column.columnDef.meta?.headerClassName, | |
| )} | |
| > | |
| {header.isPlaceholder | |
| ? null | |
| : flexRender(header.column.columnDef.header, header.getContext())} | |
| </TableHead> | |
| ); | |
| })} | |
| </TableRow> | |
| ))} | |
| </TableHeader> | |
| <TableBody ref={tableBodyRef}> | |
| {table.getRowModel().rows?.length > 0 ? ( | |
| table.getRowModel().rows?.map((row) => ( | |
| <TableRow | |
| key={row.id} | |
| className={cn( | |
| 'border-none bg-muted', | |
| rowSelectionOptions?.onClick && | |
| 'cursor-pointer data-[loading="true"]:pointer-events-none data-[loading="true"]:opacity-50', | |
| meta?.rowClassName, | |
| )} | |
| data-state={row.getIsSelected() ? 'selected' : 'unselected'} | |
| data-loading={loadingId === row.id} | |
| onClick={async (event) => { | |
| try { | |
| setLoadingId(row.id); | |
| await rowSelectionOptions?.onClick?.(row, event); | |
| } catch (error) { | |
| rowSelectionOptions?.onError?.(error, event); | |
| } finally { | |
| setLoadingId(null); | |
| } | |
| }} | |
| > | |
| {row.getVisibleCells().map((cell) => ( | |
| <TableCell | |
| key={cell.id} | |
| className={cn( | |
| 'px-2 py-2.5 sm:px-2.5 sm:py-5', | |
| 'border border-muted', | |
| 'in-[tr[data-state="selected"]]:border-primary', | |
| 'not-first:not-last:border-x-0', | |
| 'first:rounded-tl-lg first:rounded-bl-lg first:border-e-0', | |
| 'last:rounded-tr-lg last:rounded-br-lg last:border-s-0', | |
| meta?.cellClassName, | |
| cell.column.columnDef.meta?.cellClassName, | |
| )} | |
| > | |
| {flexRender(cell.column.columnDef.cell, cell.getContext())} | |
| </TableCell> | |
| ))} | |
| </TableRow> | |
| )) | |
| ) : ( | |
| <TableRow data-state="empty" className="border-none bg-muted"> | |
| <TableCell colSpan={columns.length} className="h-24 text-center"> | |
| {meta?.empty ?? t('noResults')} | |
| </TableCell> | |
| </TableRow> | |
| )} | |
| </TableBody> | |
| <TableFooter ref={tableFooterRef} /> | |
| </Table> | |
| ); | |
| if (paginationOptions?.mode === 'infinite') { | |
| return ( | |
| <InfiniteScroll | |
| itemsElement={tableBodyRef} | |
| startElement={tableHeaderRef} | |
| endElement={tableFooterRef} | |
| data={paginationOptions.data} | |
| as={paginationOptions.as} | |
| preserveUrl={paginationOptions.preserveUrl} | |
| onlyNext={paginationOptions.onlyNext} | |
| onlyPrevious={paginationOptions.onlyPrevious} | |
| reverse={paginationOptions.reverse} | |
| autoScroll={paginationOptions.autoScroll} | |
| > | |
| {children} | |
| </InfiniteScroll> | |
| ); | |
| } | |
| return children; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { useCallback, useLayoutEffect, useRef, useState } from 'react'; | |
| import { flushSync } from 'react-dom'; | |
| export function useControlledState<T>( | |
| initialValue: T, | |
| controlledValue?: T, | |
| onChange?: (value: T) => void, | |
| ): [T, (value: T | ((prev: T) => T)) => void] { | |
| const [internalValue, setInternalValue] = useState(initialValue); | |
| const isControlled = controlledValue !== undefined; | |
| const stateRef = useRef<T>(initialValue); | |
| const controlledRef = useRef<T | undefined>(controlledValue); | |
| useLayoutEffect(() => { | |
| stateRef.current = internalValue; | |
| }, [internalValue]); | |
| useLayoutEffect(() => { | |
| controlledRef.current = controlledValue; | |
| }, [controlledValue]); | |
| return [ | |
| isControlled ? controlledValue : internalValue, | |
| useCallback( | |
| (next: T | ((prev: T) => T)) => { | |
| const prev = isControlled ? controlledRef.current! : stateRef.current; | |
| const resolved = typeof next === 'function' ? (next as (p: T) => T)(prev) : next; | |
| flushSync(() => (isControlled ? onChange?.(resolved) : setInternalValue(resolved))); | |
| if (!isControlled) { | |
| onChange?.(resolved); | |
| } | |
| }, | |
| [isControlled, onChange, setInternalValue], | |
| ), | |
| ] as const; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment