Data Table Powerful table and datagrids built using TanStack Table.
Preview Code
0 of 5 row(s) selected.
Previous Next
Every data table or datagrid I've created has been unique.
They all behave differently, have specific sorting and filtering requirements, and work with different data sources.
It doesn't make sense to combine all of these variations into a single component.
If we do that, we'll lose the flexibility that headless UI provides.
So instead of a data-table component, I thought it would be more helpful to provide a guide on how to build your own.
We'll start with the basic <Table />
component and build a complex data table from scratch.
If you find yourself using the same table in multiple places in your app, you
can always extract it into a reusable component.
This guide will show you how to use TanStack Table and the <Table />
component to build your own custom data table.
We'll cover the following topics:
Add the <Table />
component to your project.
Install the @tanstack/react-table
package.
npm pnpm yarn bun
npm install @tanstack/react-table
We are going to build a table to show recent payments. Here's what our data looks like:
type Payment = {
id : string ;
amount : number ;
status : 'pending' | 'processing' | 'success' | 'failed' ;
email : string ;
};
export const payments : Payment [] = [
{
id: '728ed52f' ,
amount: 100 ,
status: 'pending' ,
email: 'm@example.com' ,
},
{
id: '489e1d42' ,
amount: 125 ,
status: 'processing' ,
email: 'example@gmail.com' ,
},
// ...
];
Start by creating the following file structure:
I'm using a Next.js (app router) example here but this works for any other React framework.
columns.tsx
(client component) will contain our column definitions.
data-table.tsx
(client component) will contain our <DataTable />
component.
page.tsx
(server component) is where we'll fetch data and render our table.
Let's start by building a basic table.
First, we'll define our columns.
'use client' ;
import { ColumnDef } from '@tanstack/react-table' ;
// This type is used to define the shape of our data.
// You can use a Zod schema here if you want.
export type Payment = {
id : string ;
amount : number ;
status : 'pending' | 'processing' | 'success' | 'failed' ;
email : string ;
};
export const columns : ColumnDef < Payment >[] = [
{
accessorKey: 'status' ,
header: 'Status' ,
},
{
accessorKey: 'email' ,
header: 'Email' ,
},
{
accessorKey: 'amount' ,
header: 'Amount' ,
},
];
Columns are where you define the core of what your table will look like.
They define the data that will be displayed, how it will be formatted, sorted and filtered.
Next, we'll create a <DataTable />
component to render our table.
app/payments/data-table.tsx 'use client' ;
import type {
ColumnDef,
ColumnFiltersState,
SortingState,
VisibilityState,
} from '@tanstack/react-table' ;
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '~/components/ui/table' ;
interface DataTableProps < TData , TValue > {
columns : ColumnDef < TData , TValue >[];
data : TData [];
};
export const DataTable <TData, TValue > = ({
columns,
data,
} : DataTableProps < TData , TValue >) => {
const table = useReactTable ({
data,
columns,
getCoreRowModel: getCoreRowModel (),
});
return (
< div className = 'rounded-md border' >
< Table >
< TableHeader >
{table. getHeaderGroups (). map (( headerGroup ) => (
< TableRow key = {headerGroup.id}>
{headerGroup.headers. map (( header ) => {
return (
< TableHead key = {header.id}>
{header.isPlaceholder
? null
: flexRender (
header.column.columnDef.header,
header. getContext ()
)}
</ TableHead >
)
})}
</ TableRow >
))}
</ TableHeader >
< TableBody >
{table. getRowModel ().rows?. length ? (
table. getRowModel ().rows. map (( row ) => (
< TableRow
key = {row.id}
data-state = {row. getIsSelected () && 'selected' }
>
{row. getVisibleCells (). map (( cell ) => (
< TableCell key = {cell.id}>
{ flexRender (cell.column.columnDef.cell, cell. getContext ())}
</ TableCell >
))}
</ TableRow >
))
) : (
< TableRow >
< TableCell colSpan = {columns. length } className = 'h-24 text-center' >
No results.
</ TableCell >
</ TableRow >
)}
</ TableBody >
</ Table >
</ div >
)
}
If you find yourself using <DataTable />
in multiple places, this is the component you could make reusable by extracting it to components/ui/data-table.tsx
.
< DataTable columns = {columns} data = {data} />
Finally, we'll render our table in our page component.
app/payments/data-table.tsx import { Payment, columns } from './columns'
import { DataTable } from './data-table'
const getData : Promise < Payment []> = async () => {
// Fetch data from your API here.
return [
{
id: '728ed52f' ,
amount: 100 ,
status: 'pending' ,
email: 'm@example.com' ,
},
// ...
];
};
const DemoPage = async () => {
const data = await getData ();
return (
< div className = 'container mx-auto py-10' >
< DataTable columns = {columns} data = {data} />
</ div >
);
};
export default DemoPage;
Let's format the amount cell to display the dollar amount. We'll also align the cell to the right.
Update the header
and cell
definitions for amount as follows:
export const columns : ColumnDef < Payment >[] = [
{
accessorKey: 'amount' ,
header : () => < div className = 'text-right' >Amount</ div >,
cell : ({ row }) => {
const amount = parseFloat (row. getValue ( 'amount' ));
const formatted = new Intl. NumberFormat ( 'en-US' , {
style: 'currency' ,
currency: 'USD' ,
}). format (amount);
return < div className = 'text-right font-medium' >{formatted}</ div >
},
},
];
You can use the same approach to format other cells and headers.
Let's add row actions to our table. We'll use a <Dropdown />
component for this.
Update our columns definition to add a new actions
column. The actions
cell returns a <Dropdown />
component.
'use client' ;
import { ColumnDef } from '@tanstack/react-table' ;
import { MoreHorizontal } from 'lucide-react' ;
import { Button } from '~/components/ui/button' ;
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '~/components/ui/dropdown-menu' ;
export const columns : ColumnDef < Payment >[] = [
// ...
{
id: 'actions' ,
cell : ({ row }) => {
const payment = row.original;
return (
< DropdownMenu >
< DropdownMenuTrigger asChild >
< Button variant = 'ghost' className = 'h-8 w-8 p-0' >
< span className = 'sr-only' >Open menu</ span >
< MoreHorizontal className = 'h-4 w-4' />
</ Button >
</ DropdownMenuTrigger >
< DropdownMenuContent align = 'end' >
< DropdownMenuLabel >Actions</ DropdownMenuLabel >
< DropdownMenuItem
onClick = {() => navigator.clipboard. writeText (payment.id)}
>
Copy payment ID
</ DropdownMenuItem >
< DropdownMenuSeparator />
< DropdownMenuItem >View customer</ DropdownMenuItem >
< DropdownMenuItem >View payment details</ DropdownMenuItem >
</ DropdownMenuContent >
</ DropdownMenu >
);
},
},
// ...
];
You can access the row data using row.original
in the cell
function.
Use this to handle actions for your row eg. use the id
to make a DELETE
call to your API.
Next, we'll add pagination to our table.
app/payments/data-table.tsx import {
ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
} from '@tanstack/react-table' ;
export const DataTable <TData, TValue > = ({
columns,
data,
} : DataTableProps < TData , TValue >) => {
const table = useReactTable ({
data,
columns,
getCoreRowModel: getCoreRowModel (),
getPaginationRowModel: getPaginationRowModel (),
});
// ...
};
This will automatically paginate your rows into pages of 10.
See the pagination docs for more information on customizing page size and implementing manual pagination.
We can add pagination controls to our table using the <Button />
component and the table.previousPage()
, table.nextPage()
API methods.
app/payments/data-table.tsx import { Button } from '~/components/ui/button' ;
export const DataTable <TData, TValue > = ({
columns,
data,
} : DataTableProps < TData , TValue >) => {
const table = useReactTable ({
data,
columns,
getCoreRowModel: getCoreRowModel (),
getPaginationRowModel: getPaginationRowModel (),
});
return (
< div >
< div className = 'rounded-md border' >
< Table >
{ /* .... */ }
</ Table >
</ div >
< div className = 'flex items-center justify-end space-x-2 py-4' >
< Button
disabled = { ! table. getCanPreviousPage ()}
size = 'small'
variant = 'outline'
onClick = {() => table. previousPage ()}
>
Previous
</ Button >
< Button
disabled = { ! table. getCanNextPage ()}
size = 'small'
variant = 'outline'
onClick = {() => table. nextPage ()}
>
Next
</ Button >
</ div >
</ div >
);
};
See Reusable Components section for a more advanced pagination component.
Let's make the email column sortable.
app/payments/data-table.tsx 'use client' ;
import { useState } from 'react' ;
import {
ColumnDef,
SortingState,
flexRender,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table' ;
export const DataTable <TData, TValue > = ({
columns,
data,
} : DataTableProps < TData , TValue >) => {
const [ sorting , setSorting ] = useState < SortingState >([]);
const table = useReactTable ({
data,
columns,
getCoreRowModel: getCoreRowModel (),
getPaginationRowModel: getPaginationRowModel (),
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel (),
state: {
sorting,
},
});
return (
< div >
< div className = 'rounded-md border' >
< Table >{ /* ... */ }</ Table >
</ div >
</ div >
);
};
We can now update the email
header cell to add sorting controls.
app/payments/data-table.tsx 'use client' ;
import { ColumnDef } from '@tanstack/react-table' ;
import { ArrowUpDown } from 'lucide-react' ;
export const columns : ColumnDef < Payment >[] = [
{
accessorKey: 'email' ,
header : ({ column }) => {
return (
< Button
variant = 'ghost'
onClick = {() => column. toggleSorting (column. getIsSorted () === 'asc' )}
>
Email
< ArrowUpDown className = 'ml-2 h-4 w-4' />
</ Button >
);
},
},
];
This will automatically sort the table (asc and desc) when the user toggles on the header cell.
Let's add a search input to filter emails in our table.
app/payments/data-table.tsx 'use client'
import { useState } from 'react' ;
import {
ColumnDef,
ColumnFiltersState,
SortingState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table' ;
import { Button } from '~/components/ui/button' ;
import { Input } from '~/components/ui/input' ;
export const DataTable <TData, TValue > = ({
columns,
data,
} : DataTableProps < TData , TValue >) => {
const [ sorting , setSorting ] = useState < SortingState >([]);
const [ columnFilters , setColumnFilters ] = useState < ColumnFiltersState >([]);
const table = useReactTable ({
data,
columns,
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel (),
getPaginationRowModel: getPaginationRowModel (),
getSortedRowModel: getSortedRowModel (),
onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel (),
state: {
sorting,
columnFilters,
},
});
return (
< div >
< div className = 'flex items-center py-4' >
< Input
placeholder = 'Filter emails...'
value = {(table. getColumn ( 'email' )?. getFilterValue () as string | undefined ) ?? '' }
onChange = {( event ) =>
table. getColumn ( 'email' )?. setFilterValue (event.target.value)
}
className = 'max-w-sm'
/>
</ div >
< div className = 'rounded-md border' >
< Table >{ /* ... */ }</ Table >
</ div >
</ div >
);
};
Filtering is now enabled for the email
column.
You can add filters to other columns as well.
See the filtering docs for more information on customizing filters.
Adding column visibility is fairly simple using @tanstack/react-table
visibility API.
app/payments/data-table.tsx 'use client' ;
import { useState } from 'react' ;
import {
ColumnDef,
ColumnFiltersState,
SortingState,
VisibilityState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table' ;
import { Button } from '~/components/ui/button' ;
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from '~/components/ui/dropdown-menu' ;
export const DataTable <TData, TValue > = ({
columns,
data,
} : DataTableProps < TData , TValue >) => {
const [ sorting , setSorting ] = useState < SortingState >([]);
const [ columnFilters , setColumnFilters ] = useState < ColumnFiltersState >([]);
const [ columnVisibility , setColumnVisibility ] = useState < VisibilityState >({});
const table = useReactTable ({
data,
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel (),
getPaginationRowModel: getPaginationRowModel (),
getSortedRowModel: getSortedRowModel (),
getFilteredRowModel: getFilteredRowModel (),
onColumnVisibilityChange: setColumnVisibility,
state: {
sorting,
columnFilters,
columnVisibility,
},
});
return (
< div >
< div className = 'flex items-center py-4' >
< Input
placeholder = 'Filter emails...'
value = {table. getColumn ( 'email' )?. getFilterValue () as string | undefined ?? '' }
onChange = {( event ) =>
table. getColumn ( 'email' )?. setFilterValue (event.target.value)
}
className = 'max-w-sm'
/>
< DropdownMenu >
< DropdownMenuTrigger asChild >
< Button variant = 'outline' className = 'ml-auto' >
Columns
</ Button >
</ DropdownMenuTrigger >
< DropdownMenuContent align = 'end' >
{table
. getAllColumns ()
. filter (
( column ) => column. getCanHide ()
)
. map (( column ) => {
return (
< DropdownMenuCheckboxItem
key = {column.id}
className = 'capitalize'
checked = {column. getIsVisible ()}
onCheckedChange = {( value ) =>
column. toggleVisibility ( !! value)
}
>
{column.id}
</ DropdownMenuCheckboxItem >
);
})}
</ DropdownMenuContent >
</ DropdownMenu >
</ div >
< div className = 'rounded-md border' >
< Table >{ /* ... */ }</ Table >
</ div >
</ div >
);
};
This adds a dropdown menu that you can use to toggle column visibility.
Next, we're going to add row selection to our table.
'use client' ;
import { ColumnDef } from '@tanstack/react-table' ;
import { Badge } from '~/components/ui/badge' ;
import { Checkbox } from '~/components/ui/checkbox' ;
export const columns : ColumnDef < Payment >[] = [
{
id: 'select' ,
header : ({ table }) => (
< Checkbox
aria-label = 'Select all'
checked = {
table. getIsAllPageRowsSelected () ||
(table. getIsSomePageRowsSelected () && 'indeterminate' )
}
onCheckedChange = {( value ) => table. toggleAllPageRowsSelected ( !! value)}
/>
),
cell : ({ row }) => (
< Checkbox
aria-label = 'Select row'
checked = {row. getIsSelected ()}
onCheckedChange = {( value ) => row. toggleSelected ( !! value)}
/>
),
enableSorting: false ,
enableHiding: false ,
},
];
app/payments/data-table.tsx export const DataTable <TData, TValue > = ({
columns,
data,
} : DataTableProps < TData , TValue >) => {
const [ sorting , setSorting ] = useState < SortingState >([]);
const [ columnFilters , setColumnFilters ] = useState < ColumnFiltersState >([]);
const [ columnVisibility , setColumnVisibility ] = useState < VisibilityState >({});
const [ rowSelection , setRowSelection ] = useState ({});
const table = useReactTable ({
data,
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel (),
getPaginationRowModel: getPaginationRowModel (),
getSortedRowModel: getSortedRowModel (),
getFilteredRowModel: getFilteredRowModel (),
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
},
});
return (
< div >
< div className = 'rounded-md border' >
< Table />
</ div >
</ div >
)
}
This adds a checkbox to each row and a checkbox in the header to select all rows.
You can show the number of selected rows using the table.getFilteredSelectedRowModel()
API.
< div className = 'flex-1 text-sm text-grey-text' >
{table. getFilteredSelectedRowModel ().rows. length } of{ ' ' }
{table. getFilteredRowModel ().rows. length } row(s) selected.
</ div >
Here are some components you can use to build your data tables.
Make any column header sortable and hideable.
import {
ArrowDownIcon,
ArrowUpIcon,
CaretSortIcon,
EyeNoneIcon,
} from '@radix-ui/react-icons' ;
import { Column } from '@tanstack/react-table' ;
import { Button } from '~/components/ui/button' ;
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '~/components/ui/dropdown-menu' ;
import { cn } from '~/utils/cn' ;
type Props < TData , TValue > =
React . HTMLAttributes < HTMLDivElement > & {
column : Column < TData , TValue >;
title : string ;
};
export const DataTableColumnHeader <TData, TValue > = ({
column,
title,
className,
} : Props < TData , TValue >) => {
if ( ! column. getCanSort ()) {
return < div className = { cn (className)}>{title}</ div >;
}
return (
< div className = { cn ( 'flex items-center space-x-2' , className)}>
< DropdownMenu >
< DropdownMenuTrigger asChild >
< Button
variant = 'ghost'
size = 'small'
className = 'data-[state=open]:bg-primary-bg-hover -ml-3 h-8'
>
< span >{title}</ span >
{column. getIsSorted () === 'desc' ? (
< ArrowDownIcon className = 'ml-2 h-4 w-4' />
) : column. getIsSorted () === 'asc' ? (
< ArrowUpIcon className = 'ml-2 h-4 w-4' />
) : (
< CaretSortIcon className = 'ml-2 h-4 w-4' />
)}
</ Button >
</ DropdownMenuTrigger >
< DropdownMenuContent align = 'start' >
< DropdownMenuItem onClick = {() => column. toggleSorting ( false )}>
< ArrowUpIcon className = 'text-grey-text mr-2 h-3.5 w-3.5' />
Asc
</ DropdownMenuItem >
< DropdownMenuItem onClick = {() => column. toggleSorting ( true )}>
< ArrowDownIcon className = 'text-grey-text mr-2 h-3.5 w-3.5' />
Desc
</ DropdownMenuItem >
< DropdownMenuSeparator />
< DropdownMenuItem onClick = {() => column. toggleVisibility ( false )}>
< EyeNoneIcon className = 'text-grey-text mr-2 h-3.5 w-3.5' />
Hide
</ DropdownMenuItem >
</ DropdownMenuContent >
</ DropdownMenu >
</ div >
);
}
export const columns = [
{
accessorKey: 'email' ,
header : ({ column }) => (
< DataTableColumnHeader column = {column} title = 'Email' />
),
},
];
Add pagination controls to your table including page size and selection count.
import { Button } from '~/components/ui/button' ;
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '~/components/ui/select' ;
import {
ChevronLeftIcon,
ChevronRightIcon,
DoubleArrowLeftIcon,
DoubleArrowRightIcon,
} from '@radix-ui/react-icons' ;
import { Table } from '@tanstack/react-table' ;
type Props < TData > = {
table : Table < TData >;
};
export const DataTablePagination <TData> = ({
table,
} : Props < TData >) => {
return (
< div className = 'flex items-center justify-between px-2' >
< div className = 'text-muted-foreground flex-1 text-sm' >
{table. getFilteredSelectedRowModel ().rows. length } of{ ' ' }
{table. getFilteredRowModel ().rows. length } row(s) selected.
</ div >
< div className = 'flex items-center space-x-6 lg:space-x-8' >
< div className = 'flex items-center space-x-2' >
< p className = 'text-sm font-medium' >Rows per page</ p >
< Select
value = { `${ table . getState (). pagination . pageSize }` }
onValueChange = {( value ) => {
table. setPageSize ( Number (value));
}}
>
< SelectTrigger className = 'h-8 w-[70px]' >
< SelectValue placeholder = {table. getState ().pagination.pageSize} />
</ SelectTrigger >
< SelectContent side = 'top' >
{[ 10 , 20 , 30 , 40 , 50 ]. map (( pageSize ) => (
< SelectItem key = {pageSize} value = { `${ pageSize }` }>
{pageSize}
</ SelectItem >
))}
</ SelectContent >
</ Select >
</ div >
< div className = 'flex w-[100px] items-center justify-center text-sm font-medium' >
Page {table. getState ().pagination.pageIndex + 1 } of{ ' ' }
{table. getPageCount ()}
</ div >
< div className = 'flex items-center space-x-2' >
< Button
icon
variant = 'outline'
className = 'hidden lg:flex'
onClick = {() => table. setPageIndex ( 0 )}
disabled = { ! table. getCanPreviousPage ()}
>
< span className = 'sr-only' >Go to first page</ span >
< DoubleArrowLeftIcon className = 'h-4 w-4' />
</ Button >
< Button
icon
variant = 'outline'
onClick = {() => table. previousPage ()}
disabled = { ! table. getCanPreviousPage ()}
>
< span className = 'sr-only' >Go to previous page</ span >
< ChevronLeftIcon className = 'h-4 w-4' />
</ Button >
< Button
icon
variant = 'outline'
onClick = {() => table. nextPage ()}
disabled = { ! table. getCanNextPage ()}
>
< span className = 'sr-only' >Go to next page</ span >
< ChevronRightIcon className = 'h-4 w-4' />
</ Button >
< Button
icon
variant = 'outline'
className = 'hidden lg:flex'
onClick = {() => table. setPageIndex (table. getPageCount () - 1 )}
disabled = { ! table. getCanNextPage ()}
>
< span className = 'sr-only' >Go to last page</ span >
< DoubleArrowRightIcon className = 'h-4 w-4' />
</ Button >
</ div >
</ div >
</ div >
);
}
< DataTablePagination table = {table} />
A component to toggle column visibility.
'use client' ;
import { DropdownMenuTrigger } from '@radix-ui/react-dropdown-menu' ;
import { MixerHorizontalIcon } from '@radix-ui/react-icons' ;
import { Table } from '@tanstack/react-table' ;
import { Button } from '~/components/ui/button' ;
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
} from '~/components/ui/dropdown-menu' ;
type Props < TData > = {
table : Table < TData >;
};
export function DataTableViewOptions < TData > = ({
table ,
} : Props < TData >) => {
return (
< DropdownMenu >
< DropdownMenuTrigger asChild >
< Button
variant = 'outline'
size = 'small'
className = 'ml-auto hidden lg:flex'
>
< MixerHorizontalIcon className = 'mr-2 h-4 w-4' />
View
</ Button >
</ DropdownMenuTrigger >
< DropdownMenuContent align = 'end' className = 'w-[150px]' >
< DropdownMenuLabel > Toggle columns </ DropdownMenuLabel >
< DropdownMenuSeparator />
{ table
. getAllColumns ()
. filter (
( column ) =>
typeof column . accessorFn !== ' undefined ' && column . getCanHide ()
)
. map (( column ) => {
return (
< DropdownMenuCheckboxItem
key = {column.id}
className = 'capitalize'
checked = {column.getIsVisible()}
onCheckedChange = {(value) => column.toggleVisibility(!!value)}
>
{ column . id }
</ DropdownMenuCheckboxItem >
)
})}
</ DropdownMenuContent >
</ DropdownMenu >
);
};
< DataTableViewOptions table = {table} />