Data Table

Powerful table and datagrids built using TanStack Table.

Introduction

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.

Table of Contents

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:

Installation

  1. Add the <Table /> component to your project:
npx shadcn@latest add @rescript-shadcn/DataTable
  1. Add tanstack/react-table dependency:
npm install @tanstack/react-table

Prerequisites

We are going to build a table to show recent payments. Here's what our data looks like:

type payment = {
  id: string
  amount: float
  status: [ #pending | #processing | #success | #failed ]
  email: string
}
 
let payments = [
  {
    id: "728ed52f",
    amount: 100.0,
    status: #pending,
    email: "m@example.com",
  },
  {
    id: "489e1d42",
    amount: 125.0,
    status: #processing,
    email: "example@gmail.com",
  },
  // ...
]

Project Structure

Start by creating the following file structure:

app
└── payments
    ├── Columns.res
    ├── DataTable.res
    └── page.res

I'm using a Next.js example here but this works for any other React framework.

  • Columns.res (client component) will contain our column definitions.
  • DataTable.res (client component) will contain our <DataTable /> component.
  • page.res (server component) is where we'll fetch data and render our table.

Basic Table

Let's start by building a basic table.

Column Definitions

First, we'll define our columns.

app/payments/Columns.res
@@directive("'use client'")
 
type payment = {
  id: string
  amount: float
  status: [ #pending | #processing | #success | #failed ]
  email: string
}
 
let columns = [
  {
    TanStack.ColumnDef.accessorKey: "status",
    header: "Status",
  },
  {
    accessorKey: "email",
    header: "Email",
  },
  {
    accessorKey: "amount",
    header: "Amount",
  },
]

<DataTable /> component

Next, we'll create a <DataTable /> component to render our table.

app/payments/DataTable.res
@@directive("'use client'")
 
module DataTable = {
  @react.component 
  let make = (~columns, ~data) => {
    let table = TanStack.useReactTable({
      data,
      columns,
      getCoreRowModel: getCoreRowModel(),
    })
 
    <div className="overflow-hidden rounded-md border">
      <TanStack.Table>
        <TanStack.TableHeader>
          {table.getHeaderGroups()->Array.map((headerGroup) => 
            <TanStack.TableRow key={headerGroup.id}>
              {headerGroup.headers->Array.map((header) => 
                  <TableHead key={header.id}>
                    {header.isPlaceholder
                      ? React.null
                      : flexRender(
                          header.column.columnDef.header,
                          header.getContext()
                        )}
                  </TableHead>
              )}
            </TanStack.TableRow>
          )}
        </TanStack.TableHeader>
        <TanStack.TableBody>
          {table.getRowModel().rows->Option.map(({length}) => length > 0)->Option.getOt(false) ? 
            table.getRowModel().rows->Array.map((row) => 
              <TanStack.TableRow
                key={row.id}
                dataState=?{row.getIsSelected() ? Some("selected") : None}
              >
                {row.getVisibleCells()->Array.map((cell) => 
                  <TanStack.TableCell key={cell.id}>
                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
                  </TanStack.TableCell>
                )}
              </TanStack.TableRow>
            )
           : <TanStack.TableRow>
              <TanStack.TableCell colSpan={columns.length} className="h-24 text-center">
                No results.
              </TanStack.TableCell>
            </TanStack.TableRow>
          }
        </TanStack.TableBody>
      </TanStack.Table>
    </div>
  }
}

Render the table

Finally, we'll render our table in our page component.

app/payments/page.res
let getData = async () => {
  // Fetch data from your API here.
  [
    {
      id: "728ed52f",
      amount: 100.0,
      status: #pending,
      email: "m@example.com",
    },
    // ...
  ]
}
 
module DemoPage = {
  @react.component 
  let make = () => {
    let data = await getData()
 
    <div className="container mx-auto py-10">
      <DataTable columns={columns} data={data} />
    </div>
  }
}

Cell Formatting

Let's format the amount cell to display the dollar amount. We'll also align the cell to the right.

Update columns definition

Update the header and cell definitions for amount as follows:

app/payments/Columns.res
let columns = [
  {
    TanStack.ColumnDef.accessorKey: "amount",
    header: () => <div className="text-right"> {"Amount"->React.string} </div>,
    cell: ({ row }) => {
      let amount = Float.parseFloat(row.getValue("amount"))
      let formatted = Intl.NumberFormat.make(
        ~locales=["en-US"], 
        ~options={
        style: "currency",
        currency: "USD",
      })->Intl.NumberFormat.format(amount)
 
      <div className="text-right font-medium">{formatted->React.string}</div>
    },
  },
]

You can use the same approach to format other cells and headers.

Row Actions

Let's add row actions to our table. We'll use a <Dropdown /> component for this.

Update columns definition

Update our columns definition to add a new actions column. The actions cell returns a <Dropdown /> component.

app/payments/columns.res
@@directive("'use client'")
 
let columns = [
  // ...
  {
    TanStack.ColumnDef.id: "actions",
    cell: ({ row }) => {
      let payment = row.original
 
        <DropdownMenu>
          <DropdownMenu.Trigger render={<Button variant=Ghost className="h-8 w-8 p-0"/>}>
              <span className="sr-only">Open menu</span>
              <MoreHorizontal className="h-4 w-4" />
          </DropdownMenu.Trigger>
          <DropdownMenu.Content align=End>
            <DropdownMenu.Label> {"Actions"->React.string}</DropdownMenu.Label>
            <DropdownMenu.Item
              onClick={_ => navigator.clipboard.writeText(payment.id)}
            >
              {"Copy payment ID"->React.string}
            </DropdownMenu.Item>
            <DropdownMenu.Separator />
            <DropdownMenu.Item> {"View customer"->React.string}</DropdownMenu.Item>
            <DropdownMenu.Item> {"View payment details"->React.string} </DropdownMenu.Item>
          </DropdownMenu.Content>
        </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.

Pagination

Next, we'll add pagination to our table.

Update <DataTable>

app/payments/DataTable.res
module DataTable = {
  @react.component 
  let make = (~columns, ~data) => {
    let table = TanStack.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.

Add pagination controls

We can add pagination controls to our table using the <Button /> component and the table.previousPage(), table.nextPage() API methods.

app/payments/DataTable.res
module DataTable = {
  @react.component 
  let make = (~columns, ~data) => {
    let table = TanStack.useReactTable({
      data,
      columns,
      getCoreRowModel: getCoreRowModel(),
      getPaginationRowModel: getPaginationRowModel(),
    })
 
    <div>
      <div className="overflow-hidden rounded-md border">
        <TanStack.Table>
          { // .... }
        </TanStack.Table>
      </div>
      <div className="flex items-center justify-end space-x-2 py-4">
        <Button
          variant=Outline
          size=Sm
          onClick={_ => table.previousPage()}
          disabled={!table.getCanPreviousPage()}
        >
          {"Previous"->React.string}
        </Button>
        <Button
          variant=Outline
          size=Sm
          onClick={_ => table.nextPage()}
          disabled={!table.getCanNextPage()}
        >
          {"Next"->React.string}
        </Button>
      </div>
    </div>
  }
}

See Reusable Components section for a more advanced pagination component.

Sorting

Let's make the email column sortable.

Update <DataTable>

app/payments/DataTable.res
@@directive("'use client'")
 
module DataTable = {
  @react.component 
  let make = (~columns, ~data) => {
    let (sorting, setSorting) = React.useState(() => [])
 
    let table = TanStack.useReactTable({
      data,
      columns,
      getCoreRowModel: TanStack.getCoreRowModel(),
      getPaginationRowModel: TanStack.getPaginationRowModel(),
      onSortingChange: setSorting,
      getSortedRowModel: TanStack.getSortedRowModel(),
      state: {
        sorting,
      },
    })
 
    <div>
      <div className="overflow-hidden rounded-md border">
        <Table>{ ... }</Table>
      </div>
    </div>
  }
}

Make header cell sortable

We can now update the email header cell to add sorting controls.

app/payments/Columns.res
@@directive("'use client'")
 
 
let columns = [
  {
    TanStack.ColumnDef.accessorKey: "email",
    header: ({ column }) => 
        <Button
          variant=Ghost
          onClick={_ => column.toggleSorting(column.getIsSorted() === "asc")}
        >
          {"Email"->React.string}
          <Icons.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.

Filtering

Let's add a search input to filter emails in our table.

Update <DataTable>

app/payments/DataTable.res
@@directive("'use client'")
 
module DataTable = {
  @react.component 
  let make = (~columns, ~data) => {
    let (sorting, setSorting) = React.useState(() => [])
    let (columnFilters, setColumnFilters) = React.useState(() => [])
 
    let table = TanStack.useReactTable({
      data,
      columns,
      onSortingChange: setSorting,
      getCoreRowModel: TanStack.getCoreRowModel(),
      getPaginationRowModel: TanStack.getPaginationRowModel(),
      getSortedRowModel: TanStack.getSortedRowModel(),
      onColumnFiltersChange: setColumnFilters,
      getFilteredRowModel: TanStack.getFilteredRowModel(),
      state: {
        sorting,
        columnFilters,
      },
    })
 
    <div>
      <div className="flex items-center py-4">
        <Input
          placeholder="Filter emails..."
          value={(table.getColumn("email")->Option.map( column => column.getFilterValue()))->Option.getOr("")}
          onChange={(event) =>
            table.getColumn("email")->Option.map(column => column.setFilterValue(event.target.value))
          }
          className="max-w-sm"
        />
      </div>
      <div className="overflow-hidden rounded-md border">
        <TanStack.Table>{ ... }</TanStack.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.

Visibility

Adding column visibility is fairly simple using @tanstack/react-table visibility API.

Update <DataTable>

app/payments/DataTable.res
"use client"@@directive("'use client'")
 
module DataTable = {
  @react.component 
  let make = (~columns, ~data) => {
    let (sorting, setSorting) = React.useState(() => [])
    let (columnFilters, setColumnFilters) = React.useState(() => [])
    let (columnVisibility: TanStack.VisibilityState.t, setColumnVisibility) = 
      React.useState(() => {})
 
  let table = useReactTable({
    data,
    columns,
    onSortingChange: setSorting,
    onColumnFiltersChange: setColumnFilters,
    getCoreRowModel: TanStack.getCoreRowModel(),
    getPaginationRowModel: TanStack.getPaginationRowModel(),
    getSortedRowModel: TanStack.getSortedRowModel(),
    getFilteredRowModel: TanStack.getFilteredRowModel(),
    onColumnVisibilityChange: setColumnVisibility,
    state: {
      sorting,
      columnFilters,
      columnVisibility,
    },
  })
 
    <div>
      <div className="flex items-center py-4">
        <Input
          placeholder="Filter emails..."
          value={table.getColumn("email")?.getFilterValue() as string}
          onChange={(event) =>
            table.getColumn("email")?.setFilterValue(event.target.value)
          }
          className="max-w-sm"
        />
        <DropdownMenu>
          <DropdownMenu.Trigger render={
            <Button variant=Outline className="ml-auto" />}>
              {"Columns"->React.string} 
          </DropdownMenu.Trigger>
          <DropdownMenu.Content align=End >
            {table.getAllColumns()
              ->Array.filter(
                (column) => column.getCanHide()
              )
              ->Array.map((column) => {
                  <DropdownMenu.CheckboxItem
                    key={column.id}
                    className="capitalize"
                    checked={column.getIsVisible()}
                    onCheckedChange={(value) =>
                      column.toggleVisibility(!!value)
                    }
                  >
                    {column.id->React.string}
                  </DropdownMenu.CheckboxItem>
              })}
          </DropdownMenu.Content>
        </DropdownMenu>
      </div>
      <div className="overflow-hidden rounded-md border">
        <Table>{ ... }</Table>
      </div>
    </div>
  }
}

This adds a dropdown menu that you can use to toggle column visibility.

Row Selection

Next, we're going to add row selection to our table.

Update column definitions

app/payments/Columns.res
@@directive("'use client'")
 
let columns = [
  {
    TanStack.ColumnDef.id: "select",
    header: ({ table }) => (
      <Checkbox
        checked={
          table.getIsAllPageRowsSelected() ||
          (table.getIsSomePageRowsSelected() && "indeterminate")
        }
        onCheckedChange={(value) => table.toggleAllPageRowsSelected(value)}
        ariaLabel="Select all"
      />
    ),
    cell: ({ row }) => (
      <Checkbox
        checked={row.getIsSelected()}
        onCheckedChange={(value) => row.toggleSelected(value)}
        ariaLabel="Select row"
      />
    ),
    enableSorting: false,
    enableHiding: false,
  },
]

Update <DataTable>

app/payments/DataTable.res
module DataTable = {
  @react.component 
  let make = (~columns, ~data) => {
    let (sorting, setSorting) = React.useState(() => [])
    let (columnFilters, setColumnFilters) = React.useState(() => [])
    let (columnVisibility: TanStack.VisibilityState.t, setColumnVisibility) = 
      React.useState(() => {})
    let (rowSelection, setRowSelection) = React.useState(() => {})
 
  let table = TanStack.useReactTable({
    data,
    columns,
    onSortingChange: setSorting,
    onColumnFiltersChange: setColumnFilters,
    getCoreRowModel: TanStack.getCoreRowModel(),
    getPaginationRowModel: TanStack.getPaginationRowModel(),
    getSortedRowModel: TanStack.getSortedRowModel(),
    getFilteredRowModel: TanStack.getFilteredRowModel(),
    onColumnVisibilityChange: setColumnVisibility,
    onRowSelectionChange: setRowSelection,
    state: {
      sorting,
      columnFilters,
      columnVisibility,
      rowSelection,
    },
  })
 
    <div>
      <div className="overflow-hidden rounded-md border">
        <TanStack.Table />
      </div>
    </div>
  }
}

This adds a checkbox to each row and a checkbox in the header to select all rows.

Show selected rows

You can show the number of selected rows using the table.getFilteredSelectedRowModel() API.

<div className="text-muted-foreground flex-1 text-sm">
  {`${table.getFilteredSelectedRowModel().rows.length->Int.toString} of 
  {table.getFilteredRowModel().rows.length->Int.toString} row(s) selected.}
</div>

Reusable Components

Here are some components you can use to build your data tables. This is from the Tasks demo.

Column header

Make any column header sortable and hideable.

examples/DataTableColumnHeader.res
module RT = DataTableDemo.RT

@send external colGetCanSort: RT.col => bool = "getCanSort"

@module("tailwind-merge")
external cn: (string, option<string>) => string = "twMerge"

@react.component
let make = (~column: RT.col, ~title: string, ~className="") => {
  if !(column->colGetCanSort) {
    <div className={cn("", Some(className))}> {title->React.string} </div>
  } else {
    let sorted = column->RT.colGetIsSorted
    <div className={cn("flex items-center gap-2", Some(className))}>
      <DropdownMenu>
        <DropdownMenu.Trigger
          render={<Button
            variant=Ghost size=Sm className="-ml-3 h-8 data-[state=open]:bg-accent"
          />}
        >
          <span> {title->React.string} </span>
          {if sorted == "desc" {
            <Icons.ArrowDown />
          } else if sorted == "asc" {
            <Icons.ArrowUp />
          } else {
            <Icons.ChevronsUpDown />
          }}
        </DropdownMenu.Trigger>
        <DropdownMenu.Content align=Start>
          <DropdownMenu.Item onClick={_ => column->RT.colToggleSorting(false)}>
            <Icons.ArrowUp />
            {"Asc"->React.string}
          </DropdownMenu.Item>
          <DropdownMenu.Item onClick={_ => column->RT.colToggleSorting(true)}>
            <Icons.ArrowDown />
            {"Desc"->React.string}
          </DropdownMenu.Item>
          <DropdownMenu.Separator />
          <DropdownMenu.Item onClick={_ => column->RT.colToggleVisibility(false)}>
            <Icons.EyeOff />
            {"Hide"->React.string}
          </DropdownMenu.Item>
        </DropdownMenu.Content>
      </DropdownMenu>
    </div>
  }
}
let columns = [
  {
    accessorKey: "email",
    header: ({ column }) => (
      <DataTableColumnHeader column={column} title="Email" />
    ),
  },
]

Pagination

Add pagination controls to your table including page size and selection count.

examples/DataTablePagination.res
module RT = DataTableDemo.RT

type paginationState = {pageSize: int, pageIndex: int}
type tableState = {pagination: paginationState}

@send external getState: RT.t<'data> => tableState = "getState"
@send external getPageCount: RT.t<'data> => int = "getPageCount"
@send external setPageIndex: (RT.t<'data>, int) => unit = "setPageIndex"
@send external setPageSize: (RT.t<'data>, int) => unit = "setPageSize"

@react.component
let make = (~table: RT.t<'data>) => {
  let state = table->getState
  let pageCount = table->getPageCount

  <div className="flex items-center justify-between px-2">
    <div className="flex-1 text-sm text-muted-foreground">
      {(table->RT.getFilteredSelectedRowModel).rows->Array.length->Int.toString->React.string}
      {" of "->React.string}
      {(table->RT.getFilteredRowModel).rows->Array.length->Int.toString->React.string}
      {" row(s) selected."->React.string}
    </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"->React.string} </p>
        <Select
          value={state.pagination.pageSize->Int.toString}
          onValueChange={(value, _) => table->setPageSize(Int.fromString(value)->Option.getOr(10))}
        >
          <Select.Trigger className="h-8 w-[70px]">
            <Select.Value placeholder={state.pagination.pageSize->Int.toString} />
          </Select.Trigger>
          <Select.Content side=BaseUi.Types.Side.Top>
            {[10, 20, 25, 30, 40, 50]
            ->Array.map(pageSize =>
              <Select.Item key={pageSize->Int.toString} value={pageSize->Int.toString}>
                {pageSize->Int.toString->React.string}
              </Select.Item>
            )
            ->React.array}
          </Select.Content>
        </Select>
      </div>
      <div className="flex w-[100px] items-center justify-center text-sm font-medium">
        {`Page ${(state.pagination.pageIndex + 1)
            ->Int.toString} of ${pageCount->Int.toString}`->React.string}
      </div>
      <div className="flex items-center space-x-2">
        <Button
          variant=Outline
          size=Icon
          className="hidden size-8 lg:flex"
          onClick={_ => table->setPageIndex(0)}
          disabled={!(table->RT.getCanPreviousPage)}
        >
          <span className="sr-only"> {"Go to first page"->React.string} </span>
          <Icons.ChevronsLeft />
        </Button>
        <Button
          variant=Outline
          size=Icon
          className="size-8"
          onClick={_ => table->RT.previousPage}
          disabled={!(table->RT.getCanPreviousPage)}
        >
          <span className="sr-only"> {"Go to previous page"->React.string} </span>
          <Icons.ChevronLeft />
        </Button>
        <Button
          variant=Outline
          size=Icon
          className="size-8"
          onClick={_ => table->RT.nextPage}
          disabled={!(table->RT.getCanNextPage)}
        >
          <span className="sr-only"> {"Go to next page"->React.string} </span>
          <Icons.ChevronRight />
        </Button>
        <Button
          variant=Outline
          size=Icon
          className="hidden size-8 lg:flex"
          onClick={_ => table->setPageIndex(pageCount - 1)}
          disabled={!(table->RT.getCanNextPage)}
        >
          <span className="sr-only"> {"Go to last page"->React.string} </span>
          <Icons.ChevronsRight />
        </Button>
      </div>
    </div>
  </div>
}
<DataTablePagination table={table} />

Column toggle

A component to toggle column visibility.

examples/DataTableViewOptions.res
@@directive("'use client'")

module RT = DataTableDemo.RT

@react.component
let make = (~table: RT.t<'data>) => {
  <DropdownMenu>
    <DropdownMenu.Trigger
      render={<Button variant=Outline size=Sm className="ml-auto hidden h-8 lg:flex" />}
    >
      <Icons.Settings2 />
      {"View"->React.string}
    </DropdownMenu.Trigger>
    <DropdownMenu.Content align=End className="w-[150px]">
      <DropdownMenu.Label> {"Toggle columns"->React.string} </DropdownMenu.Label>
      <DropdownMenu.Separator />
      <DropdownMenu.Group>
        {table
        ->RT.getAllColumns
        ->Array.filter(col => col->RT.colGetCanHide)
        ->Array.map(col =>
          <DropdownMenu.CheckboxItem
            key={col->RT.colId}
            className="capitalize"
            checked={col->RT.colGetIsVisible}
            onCheckedChange={(v, _) => col->RT.colToggleVisibility(v)}
          >
            {col->RT.colId->React.string}
          </DropdownMenu.CheckboxItem>
        )
        ->React.array}
      </DropdownMenu.Group>
    </DropdownMenu.Content>
  </DropdownMenu>
}
<DataTableViewOptions table={table} />