---
name: unity-data-table
description: >
  Load when rendering tabular data with Unity. Use it to choose DataTable for
  managed table state or primitive Table for static/custom markup, including
  filtering, pagination, virtualization, or bulk actions.
type: core
library: '@payfit/unity-components'
library_version: '2.x'
sources:
  - 'PayFit/hr-apps:libs/shared/unity/components/src/components/data-table/DataTable.tsx'
  - 'PayFit/hr-apps:libs/shared/unity/components/src/components/data-table/parts/DataTableRoot.tsx'
  - 'PayFit/hr-apps:libs/shared/unity/components/src/components/data-table/parts/DataTableBulkActions.tsx'
  - 'PayFit/hr-apps:libs/shared/unity/components/src/components/table/Table.tsx'
  - 'PayFit/hr-apps:libs/shared/unity/components/src/components/filter-toolbar/FilterToolbar.tsx'
  - 'PayFit/hr-apps:libs/shared/unity/components/src/components/filter/Filter.tsx'
  - 'PayFit/hr-apps:libs/shared/unity/components/src/docs/guides/data/Building Tables.mdx'
---

DataTable is the composite (Tanstack Table + Unity Table + pagination + empty
states + virtualization). Table is the primitive — use only when you have no
table state to manage.

## Setup

Minimum working client-side DataTable. Columns and data MUST be memoized.

```tsx
import { useMemo, useState } from 'react'

import {
  DataTable,
  DataTableRoot,
  TableCell,
  TableRow,
} from '@payfit/unity-components'
import {
  createColumnHelper,
  flexRender,
  getCoreRowModel,
  getPaginationRowModel,
  useReactTable,
} from '@tanstack/react-table'

type Employee = {
  id: string
  name: string
  position: string
  status: 'active' | 'inactive'
}

const columnHelper = createColumnHelper<Employee>()

export function EmployeeTable({ employees }: { employees: Employee[] }) {
  const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 })

  const columns = useMemo(
    () => [
      columnHelper.accessor('name', {
        header: 'Name',
        meta: { isRowHeader: true, headerClassName: 'uy:w-1/3' },
      }),
      columnHelper.accessor('position', { header: 'Position' }),
      columnHelper.accessor('status', { header: 'Status' }),
    ],
    [],
  )

  const data = useMemo(() => employees, [employees])

  const table = useReactTable({
    data,
    columns,
    getRowId: row => row.id,
    state: { pagination },
    onPaginationChange: setPagination,
    getCoreRowModel: getCoreRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
  })

  return (
    <DataTableRoot>
      <DataTable table={table} layout="fixed">
        {row => (
          <TableRow key={row.id}>
            {row.getVisibleCells().map(cell => (
              <TableCell key={cell.id}>
                {flexRender(cell.column.columnDef.cell, cell.getContext())}
              </TableCell>
            ))}
          </TableRow>
        )}
      </DataTable>
    </DataTableRoot>
  )
}
```

## Core Patterns

### Define columns with the column helper and ColumnMeta

ColumnMeta drives accessibility (`isRowHeader`), keyboard navigation
(`isFocusable: false` for cells whose children are themselves focusable like
checkboxes), `helperText` (renders a tooltip next to the header), and
`headerClassName` (required when `layout="fixed"`).

```tsx
import { Badge } from '@payfit/unity-components'
import { createColumnHelper } from '@tanstack/react-table'

const columnHelper = createColumnHelper<Employee>()

export const employeeColumns = [
  columnHelper.accessor('name', {
    id: 'employee',
    header: 'Employee',
    enableSorting: true,
    meta: { isRowHeader: true, headerClassName: 'uy:w-[260px]' },
  }),
  columnHelper.accessor('status', {
    header: 'Status',
    enableColumnFilter: true,
    filterFn: 'arrIncludesSome',
    cell: info => {
      const status = info.getValue()
      return (
        <Badge variant={status === 'active' ? 'success' : 'neutral'}>
          {status}
        </Badge>
      )
    },
    meta: { helperText: 'Active employees can sign in.' },
  }),
  columnHelper.display({
    id: 'actions',
    header: '',
    cell: ({ row }) => <RowMenu row={row.original} />,
    meta: { isFocusable: false },
  }),
]
```

### Server-side pagination

`manualPagination: true` disables Tanstack's internal slicing. Pass only the
current page's slice as `data` and supply `pageCount`.

```tsx
import { useMemo, useState } from 'react'

import { getCoreRowModel, useReactTable } from '@tanstack/react-table'

export function ServerTable({ totalCount, fetchPage }: Props) {
  const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 20 })
  const { data: pageRows = [] } = useQuery({
    queryKey: ['employees', pagination],
    queryFn: () => fetchPage(pagination.pageIndex, pagination.pageSize),
  })

  const columns = useMemo(() => employeeColumns, [])
  const data = useMemo(() => pageRows, [pageRows])

  const table = useReactTable({
    data,
    columns,
    manualPagination: true,
    pageCount: Math.ceil(totalCount / pagination.pageSize),
    state: { pagination },
    onPaginationChange: setPagination,
    getCoreRowModel: getCoreRowModel(),
  })

  return (
    <DataTableRoot>
      <DataTable table={table}>
        {row => (
          <TableRow key={row.id}>
            {row.getVisibleCells().map(cell => (
              <TableCell key={cell.id}>
                {flexRender(cell.column.columnDef.cell, cell.getContext())}
              </TableCell>
            ))}
          </TableRow>
        )}
      </DataTable>
    </DataTableRoot>
  )
}
```

### Filtering with FilterToolbar

`FilterToolbar.onChange` emits `SerializableAppliedFilter[]`. Map that onto
`table.setColumnFilters` / `table.setGlobalFilter`. `renderControl` takes any
control — it is a render function on purpose so you can drop in date pickers,
multi-selects, text fields, etc.

```tsx
import type { FilterDef } from '@payfit/unity-components'

import { FilterToolbar, Select, SelectItem } from '@payfit/unity-components'
import { getFilteredRowModel } from '@tanstack/react-table'

const filterDefs: FilterDef[] = [
  {
    id: 'status',
    label: 'Status',
    renderControl: (value, onChange) => (
      <Select selectedKey={value as string} onSelectionChange={onChange}>
        <SelectItem id="active">Active</SelectItem>
        <SelectItem id="inactive">Inactive</SelectItem>
      </Select>
    ),
    renderLabel: value => String(value),
  },
]

export function FilteredTable() {
  const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])

  const table = useReactTable({
    data,
    columns,
    state: { columnFilters, pagination },
    onColumnFiltersChange: setColumnFilters,
    onPaginationChange: setPagination,
    getCoreRowModel: getCoreRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
  })

  return (
    <>
      <FilterToolbar
        filterDefs={filterDefs}
        onChange={applied =>
          setColumnFilters(applied.map(f => ({ id: f.id, value: f.value })))
        }
      />
      <DataTableRoot>
        <DataTable table={table}>
          {row => (
            <TableRow key={row.id}>
              {row.getVisibleCells().map(cell => (
                <TableCell key={cell.id}>
                  {flexRender(cell.column.columnDef.cell, cell.getContext())}
                </TableCell>
              ))}
            </TableRow>
          )}
        </DataTable>
      </DataTableRoot>
    </>
  )
}
```

### Bulk actions with row selection

`DataTableBulkActions` lives next to `DataTable` inside `DataTableRoot`.
It auto-shows when rows are selected and provides the F6 focus trap. Use a
display column with `meta.isFocusable: false` for the checkbox.

```tsx
import {
  CheckboxStandalone,
  DataTable,
  DataTableBulkActions,
  DataTableRoot,
} from '@payfit/unity-components'

const checkboxColumn = columnHelper.display({
  id: 'select',
  header: ({ table }) => (
    <CheckboxStandalone
      isSelected={table.getIsAllPageRowsSelected()}
      isIndeterminate={table.getIsSomePageRowsSelected()}
      onChange={value => table.toggleAllPageRowsSelected(value)}
      slot="selection"
    >
      Select all
    </CheckboxStandalone>
  ),
  cell: ({ row }) => (
    <CheckboxStandalone
      isSelected={row.getIsSelected()}
      onChange={value => row.toggleSelected(value)}
      slot="selection"
    >
      Select row
    </CheckboxStandalone>
  ),
  enableSorting: false,
  meta: { isFocusable: false },
})

export function BulkTable() {
  const [rowSelection, setRowSelection] = useState({})
  const columns = useMemo(() => [checkboxColumn, ...employeeColumns], [])
  const table = useReactTable({
    data,
    columns,
    state: { rowSelection, pagination },
    onRowSelectionChange: setRowSelection,
    onPaginationChange: setPagination,
    enableRowSelection: true,
    getCoreRowModel: getCoreRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
  })

  return (
    <DataTableRoot>
      <DataTable table={table}>
        {row => (
          <TableRow key={row.id} isSelected={row.getIsSelected()}>
            {row.getVisibleCells().map(cell => (
              <TableCell key={cell.id}>
                {flexRender(cell.column.columnDef.cell, cell.getContext())}
              </TableCell>
            ))}
          </TableRow>
        )}
      </DataTable>
      <DataTableBulkActions
        table={table}
        actions={[
          { id: 'archive', label: 'Archive', onAction: rows => archive(rows) },
          { id: 'delete', label: 'Delete', onAction: rows => remove(rows) },
        ]}
      />
    </DataTableRoot>
  )
}
```

## Common Mistakes

### CRITICAL Define columns inline (not memoized)

Wrong:

```tsx
function MyTable() {
  const columns = [
    { accessorKey: 'name', header: 'Name' },
    { accessorKey: 'status', header: 'Status' },
  ]
  const table = useReactTable({ data, columns, ... })
  return <DataTable table={table}>{row => ...}</DataTable>
}
```

Correct:

```tsx
function MyTable() {
  const columns = useMemo(() => [
    { accessorKey: 'name', header: 'Name' },
    { accessorKey: 'status', header: 'Status' },
  ], [])
  const table = useReactTable({ data, columns, ... })
  return <DataTable table={table}>{row => ...}</DataTable>
}
```

A new columns array each render makes Tanstack Table re-run the whole pipeline; rows re-render even when data is unchanged.

### HIGH Return string from cell function instead of JSX

Wrong:

```tsx
{ accessorKey: 'status', cell: info => info.getValue() }
```

Correct:

```tsx
{
  accessorKey: 'status',
  cell: info => <Badge color={statusColor(info.getValue())}>{info.getValue()}</Badge>
}
```

flexRender expects ReactNode. A raw string renders unstyled and may overflow. Cell layout is the maintainer-flagged hotspot for table questions.

### HIGH Use the primitive Table when DataTable + Tanstack would do

Wrong:

```tsx
<Table layout="fixed">
  <TableHeader>…</TableHeader>
  <TableBody>
    {data.map(row => (
      <TableRow>…</TableRow>
    ))}
  </TableBody>
</Table>
```

Correct:

```tsx
const table = useReactTable({ data, columns, getCoreRowModel(),
  getPaginationRowModel(), state: { pagination }, onPaginationChange: setPagination })
<DataTableRoot>
  <DataTable table={table}>{row => /* ... */}</DataTable>
</DataTableRoot>
```

Primitive Table has no state; agent hand-rolls pagination/sorting/selection that DataTable provides out of the box.

### HIGH Manage filter state separately from FilterToolbar

Wrong:

```tsx
const [filters, setFilters] = useState([])
<FilterToolbar filterDefs={…} onChange={setFilters} />
<DataTable table={table} />
```

Correct:

```tsx
const [columnFilters, setColumnFilters] = useState([])
const [globalFilter, setGlobalFilter] = useState('')
const table = useReactTable({ /* … */ state: { columnFilters, globalFilter },
  onGlobalFilterChange: setGlobalFilter, onColumnFiltersChange: setColumnFilters,
  getFilteredRowModel() })
<FilterToolbar filterDefs={…} onChange={mapToTableState(setColumnFilters, setGlobalFilter)} />
```

FilterToolbar.onChange emits AppliedFilter[]. Agent stores them locally without mapping to table.setGlobalFilter / setColumnFilters, so rows do not actually filter.

### CRITICAL Server-side pagination without slicing data

Wrong:

```tsx
useReactTable({
  data: allEmployees,
  manualPagination: true,
  pageCount: Math.ceil(allEmployees.length / 10),
})
```

Correct:

```tsx
const currentData = useMemo(() => {
  const start = pagination.pageIndex * pagination.pageSize
  return allEmployees.slice(start, start + pagination.pageSize)
}, [pagination])
useReactTable({
  data: currentData,
  manualPagination: true,
  pageCount: Math.ceil(allEmployees.length / pagination.pageSize),
  state: { pagination },
  onPaginationChange: setPagination,
})
```

manualPagination: true tells Tanstack not to slice. Agents pass the full dataset and expect TanStack to paginate anyway.

### MEDIUM Render large datasets without enableVirtualization

Wrong:

```tsx
<DataTable table={table}>{row => …}</DataTable>
```

Correct:

```tsx
<DataTable
  table={table}
  enableVirtualization
  estimatedRowHeight={40}
  overscan={10}
>
  {row => …}
</DataTable>
```

Without virtualization, 500+ rows mount in the DOM. Keyboard nav context clones cells; reconciliation is O(n) per render.
