Skip to content

@payfit/unity-components

unity-data-table

288 linesSource

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.

This skill targets the current workspace Unity v3 API. Legacy examples are migration context.

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. Keep columns and data references stable across renders. Define static columns outside the component or memoize them; memoize derived data when its source would otherwise create a new array.

tsx
import { useMemo } from 'react'

import {
  DataTable,
  DataTableRoot,
  TableCell,
  TableRow,
} from '@payfit/unity-components'
import {
  createColumnHelper,
  createPaginatedRowModel,
  flexRender,
  rowPaginationFeature,
  tableFeatures,
  useTable,
} from '@tanstack/react-table'

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

const features = tableFeatures({
  rowPaginationFeature,
  paginatedRowModel: createPaginatedRowModel(),
})

const columnHelper = createColumnHelper<typeof features, Employee>()

export function EmployeeTable({ employees }: { employees: Employee[] }) {
  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 = useTable({
    features,
    data,
    columns,
  })

  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 createColumnHelper and use ColumnMeta for row headers, focus behavior, helper text, and fixed-layout widths.
  • For server pagination, pass only the current page, set manualPagination, and derive pageCount from the server total.
  • Map FilterToolbar.onChange into TanStack Table's column/global filter state.
  • Put DataTableBulkActions beside DataTable inside DataTableRoot and drive it from row-selection state.

Read references/patterns.md when implementing one of these patterns.

Common Mistakes

CRITICAL Define columns inline (not memoized)

Wrong:

tsx
function MyTable() {
  const columns = [
    { accessorKey: 'name', header: 'Name' },
    { accessorKey: 'status', header: 'Status' },
  ]
  const table = useTable({ features, 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 = useTable({ features, 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 features = tableFeatures({
  rowPaginationFeature,
  paginatedRowModel: createPaginatedRowModel(),
})
const table = useTable({ features, data, columns })
<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 features = tableFeatures({
  columnFilteringFeature,
  globalFilteringFeature,
  filteredRowModel: createFilteredRowModel(),
})
const table = useTable({ features, data, columns })
<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.

HIGH Pass every local row with manual pagination

Wrong:

tsx
useTable({
  features: tableFeatures({ rowPaginationFeature }),
  data: allEmployees,
  manualPagination: true,
  rowCount: allEmployees.length,
})

Correct for locally held data:

tsx
import { useMemo, useState } from 'react'

const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 })
const currentData = useMemo(() => {
  const start = pagination.pageIndex * pagination.pageSize
  return allEmployees.slice(start, start + pagination.pageSize)
}, [pagination])
useTable({
  features: tableFeatures({ rowPaginationFeature }),
  data: currentData,
  manualPagination: true,
  rowCount: allEmployees.length,
  state: { pagination },
  onPaginationChange: setPagination,
})

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

For server-side pagination, fetch and pass only the current server page and set rowCount or pageCount from the server response. When all rows are local and TanStack should paginate them automatically, omit manualPagination and register paginatedRowModel: createPaginatedRowModel() in tableFeatures.

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.