Data Table
A simplified wrapper for Shadcn Data Table with flexible option handling and automatic type conversion
| Status | ||||||||
|---|---|---|---|---|---|---|---|---|
EMP-1000 | John Doe | john.doe@company.com | Engineering | Manager | Active | $125,064.00 | 2020-04-06 | New York |
EMP-1001 | Jane Smith | jane.smith@company.com | Marketing | Senior | Inactive | $92,198.00 | 2022-02-26 | San Francisco |
EMP-1002 | Mike Johnson | mike.johnson@company.com | Sales | Junior | Pending | $135,288.00 | 2021-03-05 | London |
EMP-1003 | Sarah Williams | sarah.williams@company.com | HR | Lead | On Leave | $113,671.00 | 2022-10-14 | Tokyo |
EMP-1004 | David Brown | david.brown@company.com | Finance | Intern | Active | $91,958.00 | 2024-03-31 | Berlin |
EMP-1005 | Emily Davis | emily.davis@company.com | Engineering | Manager | Inactive | $98,829.00 | 2020-04-25 | New York |
EMP-1006 | Michael Wilson | michael.wilson@company.com | Marketing | Senior | Pending | $60,462.00 | 2021-12-02 | San Francisco |
EMP-1007 | Jessica Moore | jessica.moore@company.com | Sales | Junior | On Leave | $108,731.00 | 2024-08-11 | London |
EMP-1008 | Christopher Taylor | christopher.taylor@company.com | HR | Lead | Active | $135,646.00 | 2022-09-25 | Tokyo |
EMP-1009 | Amanda Anderson | amanda.anderson@company.com | Finance | Intern | Inactive | $119,732.00 | 2024-08-03 | Berlin |
Rows per page
Installation
npx shadcn@latest add @glrk-ui/data-tableData table with virtualization.
npx shadcn@latest add @glrk-ui/data-table @glrk-ui/data-table-virtualIf you haven't set up the prerequisites yet, check out Prerequest section.
npm install @tanstack/react-tableAdd following shadcn components: button, table, popover, dropdown-menu, select, badge.
Update same components from our site.
'use client'
import { useEffect, useState } from 'react'
import { Column } from '@tanstack/react-table'
import { getLabel, getValue, isGroup } from '@/lib/utils'
import { ComboboxWrapper, type ComboboxWrapperProps } from '@/components/ui/combobox'
interface ColumnFacetedFilterProps<TData, TValue>
extends Omit<ComboboxWrapperProps, 'options' | 'value' | 'onValueChange' | 'label'> {
column?: Column<TData, TValue>
title: React.ReactNode
options: optionsT
}
function change(option: allowedPrimitiveT | optionT, facets?: Map<any, number>) {
const value = getValue(option)
const label = getLabel(option)
return {
label: (
<>
{label}
{facets?.get(value) && (
<span className="ml-auto flex h-4 w-4 items-center justify-center text-xs">
{facets.get(value)}
</span>
)}
</>
),
value,
}
}
export function ColumnFacetedFilter<TData, TValue>({
column,
title,
options,
...props
}: ColumnFacetedFilterProps<TData, TValue>) {
const [facets, setFacets] = useState<Map<unknown, number> | undefined>()
useEffect(() => {
setFacets(column?.getFacetedUniqueValues())
}, [column])
const newOptions = options.map(option => {
if (isGroup(option)) {
return {
...option,
options: option.options.map(o => change(o, facets)),
}
}
return change(option, facets)
})
function onSelect(selected: allowedPrimitiveT[]) {
column?.setFilterValue(selected?.length ? selected : undefined)
}
return (
<ComboboxWrapper
multiple
items={newOptions}
value={(column?.getFilterValue() as string[]) ?? []}
onValueChange={v => onSelect(v as any)}
// label={typeof title === "object" ? title : <span className="font-semibold">{title}</span>}
indicatorAt="left"
{...props}
/>
)
}
import { Column } from '@tanstack/react-table'
import { ComboboxWrapper, type ComboboxWrapperProps } from '@/components/ui/combobox'
interface ColumndFilterProps<TData, TValue>
extends Omit<ComboboxWrapperProps, 'options' | 'value' | 'onValueChange' | 'label'> {
column?: Column<TData, TValue>
title: React.ReactNode
options: optionsT
}
export function ColumnFilter<TData, TValue>({
column,
title,
options,
...props
}: ColumndFilterProps<TData, TValue>) {
function onSelect(selected: allowedPrimitiveT) {
column?.setFilterValue(selected ?? undefined)
}
return (
<ComboboxWrapper
items={options}
value={column?.getFilterValue() ?? ""}
onValueChange={v => onSelect(v as any)}
// label={typeof title === "object" ? title : <span className="font-semibold">{title}</span>}
{...props}
/>
)
}
import { ArrowDown, ArrowUp, ChevronsUpDown, EyeOff } from 'lucide-react'
import { Column } from '@tanstack/react-table'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from '@/components/ui/menu'
interface ColumnHeaderProps<TData, TValue> {
className?: string
column: Column<TData, TValue>
title: React.ReactNode
}
export function ColumnHeader<TData, TValue>({
column,
title,
className,
}: ColumnHeaderProps<TData, TValue>) {
if (!column.getCanSort()) return <div className={cn(className)}>{title}</div>
return (
<Menu>
<MenuTrigger className={cn(buttonVariants({ variant: 'ghost', className }), '-ml-2')}>
{title}
{column.getIsSorted() === 'desc' ? (
<ArrowDown />
) : column.getIsSorted() === 'asc' ? (
<ArrowUp />
) : (
<ChevronsUpDown />
)}
</MenuTrigger>
<MenuContent align="start">
<MenuItem
onClick={() =>
column.getIsSorted() !== 'asc' ? column.toggleSorting(false) : column.clearSorting()
}
>
<ArrowUp className="h-3.5 w-3.5 text-muted-foreground/70" />
Asc
</MenuItem>
<MenuItem
onClick={() =>
column.getIsSorted() !== 'desc' ? column.toggleSorting(true) : column.clearSorting()
}
>
<ArrowDown className="h-3.5 w-3.5 text-muted-foreground/70" />
Desc
</MenuItem>
<MenuSeparator />
<MenuItem onClick={() => column.toggleVisibility(false)}>
<EyeOff className="h-3.5 w-3.5 text-muted-foreground/70" />
Hide Column
</MenuItem>
</MenuContent>
</Menu>
)
}
import { ChevronsDown, ChevronsUp, ChevronsUpDown } from 'lucide-react'
import { Column } from '@tanstack/react-table'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
interface ColumnHeaderProps<TData, TValue> {
className?: string
column: Column<TData, TValue>
title: React.ReactNode
}
export function ColumnSorter<TData, TValue>({
column,
title,
className,
}: ColumnHeaderProps<TData, TValue>) {
if (!column.getCanSort()) return <div className={cn(className)}>{title}</div>
const sorted = column.getIsSorted()
function onSort() {
if (sorted === 'asc') {
column.toggleSorting(true)
} else if (sorted === 'desc') {
column.clearSorting()
} else {
column.toggleSorting(false)
}
}
return (
<Button variant="ghost" className={cn('-ml-2', className)} onClick={onSort}>
{title}
{sorted === 'desc' ? (
<ChevronsDown className="ml-4 opacity-80" />
) : sorted === 'asc' ? (
<ChevronsUp className="ml-4 opacity-80" />
) : (
<ChevronsUpDown className="ml-4 opacity-80" />
)}
</Button>
)
}
'use client'
import { Settings2 } from 'lucide-react'
import { Table } from '@tanstack/react-table'
import { buttonVariants } from '@/components/ui/button'
import { Menu, MenuCheckboxItem, MenuContent, MenuTrigger } from '@/components/ui/menu'
interface ColumnToggleProps<TData> {
table: Table<TData>
}
export function ColumnToggle<TData>({ table }: ColumnToggleProps<TData>) {
return (
<Menu>
<MenuTrigger className={buttonVariants({ variant: 'outline' })}>
<Settings2 />
View
</MenuTrigger>
<MenuContent align="end" className="w-38">
{/* <MenuLabel>Toggle columns</MenuLabel> */}
{/* <MenuSeparator /> */}
{table
.getAllColumns()
.filter(column => typeof column.accessorFn !== 'undefined' && column.getCanHide())
.map(column => (
<MenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={value => column.toggleVisibility(!!value)}
>
{column.id?.replace('_', ' ')}
</MenuCheckboxItem>
))}
</MenuContent>
</Menu>
)
}
'use client'
import { flexRender, Table as TanstackTable } from '@tanstack/react-table'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
interface DataTableProps<TData> {
table: TanstackTable<TData>
emptyMessage?: string
className?: string
}
export function DataTable<TData>({
table,
emptyMessage = 'No matching results.',
className = '',
}: DataTableProps<TData>) {
const columnCount = table?.getAllColumns()?.length
const rows = table?.getRowModel()?.rows
const hasRows = rows?.length > 0
return (
<Table className={className}>
<TableHeader>
{table?.getHeaderGroups().map(headerGroup => (
<TableRow key={headerGroup.id} className="hover:bg-transparent">
{headerGroup.headers.map(header => (
<TableHead key={header.id} className="text-theme-grey-text">
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{hasRows ? (
rows.map(row => (
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
{row.getVisibleCells().map(cell => (
<TableCell key={cell.id} className="text-[13px] capitalize">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={columnCount} className="border-b">
<div className="dc h-32 my-4 text-sm text-center">{emptyMessage}</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)
}
import { useState } from 'react'
import { CirclePlus } from 'lucide-react'
import { Table } from '@tanstack/react-table'
import { type ComboboxWrapperProps } from '@/components/ui/combobox'
import { MenuCheckboxWrapper } from '@/components/ui/menu-wrapper'
import { buttonVariants } from '@/components/ui/button'
import { ColumnFilter } from './column-filter'
type ColumnFilterPassthroughProps = Omit<ComboboxWrapperProps, 'items' | 'value' | 'onValueChange' | 'label' | 'multiple'>
type MenuCheckboxPassthroughProps = Omit<React.ComponentProps<typeof MenuCheckboxWrapper>, 'trigger' | 'options' | 'checked' | 'onCheckedChange'>
interface FilterGroupProps<TData> {
table: Table<TData>
options: {
value: string
lable: React.ReactNode
options: optionsT
columnFilterProps?: ColumnFilterPassthroughProps
}[]
columnFilterProps?: ColumnFilterPassthroughProps
menuCheckboxProps?: MenuCheckboxPassthroughProps
}
export function FilterGroup<TData>({ table, options, columnFilterProps, menuCheckboxProps }: FilterGroupProps<TData>) {
const [selected, setSelected] = useState<allowedPrimitiveT[]>([])
return (
<>
{options
.filter(f => selected.includes(f.value))
.map(opt => (
<ColumnFilter
key={opt.value}
title={opt.lable}
column={table.getColumn(opt.value)}
options={opt.options}
{...columnFilterProps}
{...opt.columnFilterProps}
/>
))}
<MenuCheckboxWrapper
trigger={<><CirclePlus className="size-4" /> Filter</>}
triggerCls={buttonVariants({ variant: 'outline' })}
{...menuCheckboxProps}
checked={selected}
onCheckedChange={(val, checked) =>
setSelected(prev => (!checked ? prev.filter(p => !p) : [...prev, val]))
}
options={options.map(m => ({ label: m.lable, value: m.value }))}
/>
</>
)
}
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react'
import { Table } from '@tanstack/react-table'
import { SelectWrapper } from '@/components/ui/select'
import { Button } from '@/components/ui/button'
interface PaginationProps<TData> {
table: Table<TData>
}
export function Pagination<TData>({ table }: PaginationProps<TData>) {
return (
<div className="flex items-center justify-between flex-wrap gap-2 px-2 mt-4">
<div className="flex-1 whitespace-nowrap text-sm text-muted-foreground">
Total: {table.getFilteredRowModel().rows.length} row(s)
</div>
<div className="flex items-center gap-2">
<p className="text-xs">Rows per page</p>
<SelectWrapper
value={`${table.getState().pagination.pageSize}`}
onValueChange={value => table.setPageSize(Number(value))}
options={[10, 20, 30, 40, 50]}
placeholder={`${table.getState().pagination.pageSize}`}
triggerCls="h-8 w-[70px]"
/>
</div>
<div className="text-xs mx-2">
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to first page</span>
<ChevronsLeft className="size-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to previous page</span>
<ChevronLeft className="size-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to next page</span>
<ChevronRight className="size-4" />
</Button>
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to last page</span>
<ChevronsRight className="size-4" />
</Button>
</div>
</div>
)
}
'use client'
import { useState } from 'react'
import {
ColumnDef,
ColumnFiltersState,
SortingState,
VisibilityState,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table'
interface useTableProps<TData, TValue> {
data: TData[]
columns: ColumnDef<TData, TValue>[]
}
export function useTable<TData, TValue>({ data, columns }: useTableProps<TData, TValue>) {
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [sorting, setSorting] = useState<SortingState>([])
const [rowSelection, setRowSelection] = useState({})
const [globalFilter, setGlobalFilter] = useState('')
return useReactTable({
data,
columns,
state: {
sorting,
columnVisibility,
rowSelection,
columnFilters,
globalFilter,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onGlobalFilterChange: setGlobalFilter,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
})
}
export default useTable
export * from './column-faceted-filter'
export * from './column-header'
export * from './column-filter'
export * from './column-sorter'
export * from './column-toggle'
export * from './filter-group'
export * from './pagination'
export * from './data-table'
export * from './use-table'
If you wish to add DataTableVirtualized
npm install @tanstack/react-virtual'use client'
import { useEffect, useRef } from 'react'
import { type VirtualizerOptions, useVirtualizer } from '@tanstack/react-virtual'
import { flexRender, Table as TanstackTable } from '@tanstack/react-table'
import { Loader } from 'lucide-react'
import { cn } from '@/lib/utils'
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
interface DataTableProps<TData> {
table: TanstackTable<TData>
emptyMessage?: string
className?: string
hasNextPage?: boolean
isFetchingNextPage?: boolean
fetchNextPage?: () => void
virtualizerOptions?: Partial<
Omit<VirtualizerOptions<HTMLDivElement, Element>, 'count' | 'getScrollElement'>
>
}
export function DataTableVirtualized<TData>({
table,
emptyMessage = 'No matching results.',
className = '',
hasNextPage = false,
isFetchingNextPage = false,
fetchNextPage = () => {},
virtualizerOptions,
}: DataTableProps<TData>) {
const rows = table.getRowModel().rows
const columnCount = table.getAllColumns().length
const hasRows = rows.length > 0
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: hasNextPage ? rows.length + 1 : rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 81,
overscan: 10,
...(virtualizerOptions ?? {}),
})
const virtualItems = virtualizer.getVirtualItems()
useEffect(() => {
const [lastItem] = [...virtualItems].reverse()
if (!lastItem) return
if (lastItem.index >= rows.length - 1 && hasNextPage && !isFetchingNextPage) {
fetchNextPage?.()
}
}, [rows.length, hasNextPage, virtualItems, isFetchingNextPage, fetchNextPage])
return (
<div ref={parentRef} className={cn('overflow-auto', className)}>
<div style={{ height: `${virtualizer.getTotalSize()}px` }}>
<table className="w-full caption-bottom text-sm">
<TableHeader>
{table.getHeaderGroups().map(headerGroup => (
<TableRow key={headerGroup.id} className="hover:bg-transparent">
{headerGroup.headers.map(header => (
<TableHead key={header.id} className="text-theme-grey-text">
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{hasRows ? (
virtualItems.map((virtualRow, index) => {
const isLoaderRow = virtualRow.index > rows.length - 1
const row = rows[virtualRow.index]
if (!row) {
if (isLoaderRow && hasNextPage) {
return (
<TableRow
key="loader"
ref={virtualizer.measureElement}
data-index={virtualRow.index}
style={{
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start - index * virtualRow.size}px)`,
}}
>
<TableCell colSpan={columnCount}>
<div className="dc">
<Loader className="animate-spin" />
</div>
</TableCell>
</TableRow>
)
}
return null
}
return (
<TableRow
key={row.id}
ref={virtualizer.measureElement}
data-state={row?.getIsSelected?.() && 'selected'}
data-index={virtualRow.index}
className="hover:bg-muted/40"
style={{
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start - (isLoaderRow ? index - 1 : index) * virtualRow.size}px)`,
}}
>
{row?.getVisibleCells()?.map(cell => (
<TableCell key={cell.id} className="text-[13px] capitalize">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)
})
) : (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={columnCount} className="border-b">
<div className="dc h-32 my-4 text-sm text-center">{emptyMessage}</div>
</TableCell>
</TableRow>
)}
</TableBody>
</table>
</div>
</div>
)
}
add import statement in the ui/data-table/index.tsx
export * from "./data-table-virtualized";
export * from "./column-faceted-filter";
export * from "./column-header";
export * from "./column-filter";
export * from "./column-sorter";
export * from "./column-toggle";
export * from "./filter-group";
export * from "./pagination";
export * from "./data-table";
export * from "./use-table";Usage
DataTable
import {
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import { DataTable } from '@/components/ui/data-table';
function MyTable() {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
})
return <DataTable table={table} />
}DataTableVirtualized
import {
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import { DataTableVirtualized } from '@/components/ui/data-table';
function MyTable() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery()
const table = useReactTable({
data: data.pages.flat(),
columns,
getCoreRowModel: getCoreRowModel(),
})
return (
<DataTableVirtualized
table={table}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
fetchNextPage={fetchNextPage}
className="h-[600px]" // Fixed height required
/>
)
}useTable Hook
Custom hook for managing table state and configuration. Pre-configured with columnVisibility, columnFilters (Faceted), sorting, rowSelection, globalFilter, pagination.
So you can use most of the functionality of tanstack-table just using this hook.
import { useTable, DataTable } from '@/components/ui/data-table';
function MyTable() {
const table = useTable({ data, columns })
return (
<>
//... Other components
<DataTable table={table} />
</>
)
}ColumnSorter
Simple column header with sorting functionality.
const columns: ColumnDef<User>[] = [
{
accessorKey: 'name',
header: ({ column }) => (
<ColumnSorter column={column} title="Name" />
),
},
]ColumnHeader
Advanced column header with dropdown menu for sorting and visibility.
const columns: ColumnDef<User>[] = [
{
accessorKey: 'name',
header: ({ column }) => (
<ColumnHeader column={column} title="Name" />
),
},
]ColumnFilter
Multi-select dropdown filter for column values.
<ColumnFilter
title="Role"
column={table.getColumn('role')}
options={['Manager', 'Senior', 'Junior', 'Lead', 'Intern']}
/>ColumnFacetedFilter
Multi-select dropdown filter with faceted value counts.
<ColumnFacetedFilter
title="Role"
column={table.getColumn('role')}
options={['Manager', 'Senior', 'Junior', 'Lead', 'Intern']}
/>FilterGroup
Dynamic filter management component that allows adding/removing filters (ColumnFilter).
<FilterGroup
table={table}
options={[
{
key: 'department',
lable: 'Department',
options: ['Engineering', 'Marketing', 'Sales']
},
{
key: 'location',
lable: 'Location',
options: ['New York', 'London', 'Tokyo']
}
]}
/>ColumnToggle
Dropdown menu for showing/hiding table columns.
<ColumnToggle table={table} />Pagination
Comprehensive pagination controls with page size selection.
<Pagination table={table} />Note: options array is of optionsT type, so you can pass allowed data. DataTable fully customizable with column array itself.
Reference
ColumndFilter and ColumnFacetedFilter
Prop
Type
ColumnHeader
Prop
Type
ColumnSorter
Prop
Type
ColumnToggle and Pagination
Prop
Type
useTable
Prop
Type
DataTable
Prop
Type
DataTableVirtualized
Prop
Type