Last active
November 27, 2025 09:29
-
-
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 { 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?.state, | |
| sortingOptions?.initialState ?? [], | |
| sortingOptions?.onChange, | |
| ); | |
| const [columnFilters, onColumnFiltersChange] = useControlledState( | |
| filteringOptions?.state, | |
| filteringOptions?.initialState ?? [], | |
| filteringOptions?.onChange, | |
| ); | |
| const [columnVisibility, onColumnVisibilityChange] = useControlledState( | |
| visibilityOptions?.state, | |
| visibilityOptions?.initialState ?? {}, | |
| visibilityOptions?.onChange, | |
| ); | |
| const [rowSelection, onRowSelectionChange] = useControlledState( | |
| rowSelectionOptions?.state, | |
| rowSelectionOptions?.initialState ?? {}, | |
| rowSelectionOptions?.onChange, | |
| ); | |
| const [pagination, onPaginationChange] = useControlledState( | |
| paginationOptions?.mode !== 'infinite' ? paginationOptions?.state : undefined, | |
| paginationOptions?.mode !== 'infinite' && paginationOptions?.initialState !== undefined | |
| ? paginationOptions.initialState | |
| : { pageSize: 10, pageIndex: 0 }, | |
| 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, useState } from 'react'; | |
| export function useControlledState<T>( | |
| controlledValue: T | undefined, | |
| initialValue: T, | |
| onChange?: (value: T) => void, | |
| ): [T, (value: T | ((prev: T) => T)) => void] { | |
| const [internalState, setInternalState] = useState(initialValue); | |
| const isControlled = controlledValue !== undefined; | |
| const state = isControlled ? controlledValue : internalState; | |
| const setState = useCallback( | |
| (value: T | ((prev: T) => T)) => { | |
| const next = typeof value === 'function' ? (value as (prev: T) => T)(state) : value; | |
| if (!isControlled) { | |
| setInternalState(next); | |
| } | |
| onChange?.(next); | |
| }, | |
| [isControlled, onChange, state, setInternalState], | |
| ); | |
| return [state, setState]; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment