ProTable

ProTable is an opinionated wrapper around TanStack Table that gives you sorting, column filtering, pagination, row expansion, loading skeletons, and a set of prebuilt cell renderers out of the box — while still exposing the underlying table instance when you need full control.

ProTable is headless-friendly: pass your own table instance (built with useProTable) if you need to share state with a ProTableToolbar or drive filters from outside the table.

Installation

npm install @tanstack/react-table @tabler/icons-react clsx

Quick start

import { ProTable, useProTable, type ProColumnMeta } from '@your-scope/protable'
import type { ColumnDef } from '@tanstack/react-table'
 
interface User {
  id: string
  name: string
  email: string
  active: boolean
}
 
const columns: ColumnDef<ProTableFeatures, User, any>[] = [
  {
    accessorKey: 'name',
    meta: { label: 'Name' } satisfies ProColumnMeta
  },
  {
    accessorKey: 'email',
    meta: {
      label: 'Email',
      filter: { variant: 'text', label: 'Email' }
    } satisfies ProColumnMeta
  },
  {
    accessorKey: 'active',
    meta: {
      label: 'Active',
      filter: { variant: 'boolean', label: 'Active' }
    } satisfies ProColumnMeta
  }
]
 
export function UsersTable({ data }: { data: User[] }) {
  return <ProTable data={data} columns={columns} />
}

Toolbar + sorting + filters together

Filtering, sorting menus, and the table body all need to share one table instance. Build it once with useProTable and pass it down explicitly:

import { ProTable, ProTableToolbar, useProTable } from '@your-scope/protable'
 
export function UsersTable({ data, columns }) {
  const table = useProTable({ data, columns })
 
  return (
    <>
      <ProTableToolbar table={table} />
      <ProTable table={table} data={data} columns={columns} />
    </>
  )
}
⚠️

If you don’t pass table, ProTable builds its own instance internally — which means a separately rendered ProTableToolbar or ProTableSortList won’t see the same state. Share one instance whenever you use those components together.

ProTable props

PropTypeDefaultDescription
dataTData[]Row data.
columnsColumnDef<ProTableFeatures, TData, any>[]Column definitions. Use meta to configure label, icon, shrink, and filters.
tableReturnType<typeof useProTable<TData>>Bring your own table instance (see above). If omitted, one is created internally.
sorting / onSortingChangeSortingState / OnChangeFnControlled sorting.
pagination / onPaginationChangePaginationState / OnChangeFnControlled pagination.
columnFilters / onColumnFiltersChangeColumnFiltersState / OnChangeFnControlled column filters.
manualSorting / manualPagination / manualFilteringbooleanfalseSet when the server handles that operation.
rowCountnumberTotal row count on the server, for manual pagination.
enableSortingbooleantrueGlobally enable/disable sorting.
enableExpandingbooleanfalseEnables expandable rows; the first cell of each row becomes the expand toggle.
getSubRows(row: TData) => TData[] | undefinedRequired alongside enableExpanding for tree data.
expanded / onExpandedChangeExpandedState / OnChangeFnControlled expansion state.
loadingbooleanfalseRenders skeletonRowCount skeleton rows instead of data.
skeletonRowCountnumber5Number of skeleton rows while loading.
emptyMessagestring'No records to display'Shown when data is empty.
errorMessagestringOverrides emptyMessage when set.
hidePaginationbooleanfalseHides the pagination bar entirely.
withBorderbooleantrueAdds a border/rounded card around the table.
stickyHeaderbooleanfalseSticks the header to the top of the scroll container.
onRowClick(row: TData) => voidMakes rows clickable.
rowKey(row: TData, index: number) => string | numberCustom row id, passed to TanStack’s getRowId.
paginationPropsTablePaginationPropsForwarded to ProTablePagination.
wrapperPropsBoxPropsForwarded to the outer Box.
classNamestringApplied to the outer wrapper.

Column widths

Columns are sized deterministically from meta.shrink, not from TanStack’s column-sizing defaults:

  • shrink: true columns get a fixed 180px and never wrap (white-space: nowrap) — use this for things like an actions column.
  • Every other column splits the remaining width evenly via calc((100% - shrinkPx) / growCount).
{
  id: 'actions',
  meta: { shrink: true }, // or use the actionsColumnMeta() helper
  cell: ({ row }) => <ActionsCell onEdit={...} onDelete={...} editIcon={IconPencil} deleteIcon={IconTrash} />,
}

ProColumnMeta

Set this on columnDef.meta to configure a column’s header, width behavior, and filter UI.

interface ProColumnMeta {
  shrink?: boolean // fixed 180px, no wrap — use for narrow/actions columns
  noEllipsis?: boolean // allow primary/secondary text to wrap instead of truncating
  label?: string // header label (falls back to column id)
  icon?: React.ComponentType<{ size?: number; className?: string }>
  filter?: ProColumnFilterMeta // enables a filter control in ProTableToolbar
}

Filtering

ProColumnFilterMeta

interface ProColumnFilterMeta {
  label: string
  variant: 'text' | 'number' | 'range' | 'date' | 'dateRange' | 'boolean' | 'select' | 'multiSelect'
  options?: FilterOption[] // required for 'select' / 'multiSelect'
  placeholder?: string
  unit?: string // shown as a right-section suffix on 'number' inputs
}

Any column with meta.filter set automatically renders a matching input inside ProTableToolbar, via ColumnFilterInput.

VariantControlFilter value shape
textText inputstring
numberNumeric input (optional unit suffix)string
booleanSelect (True / False)'true' | 'false'
selectSearchable, clearable selectstring[] (single value wrapped in an array)
multiSelectSearchable, clearable multi-selectstring[]
datePopover calendar with a clear buttonnumber (epoch ms)
dateRangePopover calendar in range mode[number, number] (epoch ms)
rangeNot yet implemented — needs a RangeSlider primitive
⚠️

The range variant currently renders nothing (return null). Wire up a RangeSlider primitive before using it, or filter it out of your column config.

Filter functions

getFilterFn(variant) maps a filter variant to the right TanStack filterFn:

VariantfilterFn
select, multiSelect'arrIncludesSome'
boolean'equals'
number, range'inNumberRange'
datecustom dateEquals (compares by day, ignoring time-of-day)
dateRangecustom dateRangeBetween (inclusive bounds)
text (default)'includesString'

Pass it on the column definition:

{
  accessorKey: 'createdAt',
  filterFn: getFilterFn('date'),
  meta: { label: 'Created', filter: { variant: 'date', label: 'Created' } },
}

Filter operators (advanced / builder UIs)

getFilterOperators(variant) and getDefaultFilterOperator(variant) return the operator list for building a custom filter-builder UI (e.g. “contains”, “is between”, “is empty”). These are metadata helpers only — they describe available operators per variant but aren’t wired into ColumnFilterInput automatically.

type FilterOperator =
  | 'eq'
  | 'ne'
  | 'contains'
  | 'notContains'
  | 'startsWith'
  | 'endsWith'
  | 'gt'
  | 'gte'
  | 'lt'
  | 'lte'
  | 'isBetween'
  | 'isEmpty'
  | 'isNotEmpty'
  | 'isAnyOf'
  | 'isNoneOf'

Sorting

ProTableColumnHeader renders each header as a menu (Asc / Desc / Reset, plus Hide when the column supports it) and shows the current sort direction as an icon.

For multi-column sorting, drop ProTableSortList into your toolbar — it lets users add, reorder, and remove sort criteria from a menu:

<ProTableToolbar table={table} showSort />
// or standalone:
<ProTableSortList table={table} />

ProTableToolbar renders ProTableSortList by default (showSort defaults to true); pass showSort={false} to omit it.

Pagination

ProTablePagination is rendered automatically by ProTable unless hidePagination is set. Configure it via paginationProps:

PropTypeDefaultDescription
pageSizeOptionsnumber[][5, 10, 15, 20, 25, 30, 50, 100]Options for the rows-per-page select.
showRowsPerPagebooleanfalseShows the rows-per-page control.
showTotalbooleanfalseShows the total row count.
showSelectedCountbooleanfalseShows “x of y row(s) selected” (requires row selection).
localizationobjectOverride the strings above, including pageOf(current, total) and rowsSelected(selected, total).
wrapperPropsFlexPropsForwarded to the outer Flex.

showTotal uses table.getRowCount() (the server-reported total in manual mode), while the filtered count used elsewhere comes from getFilteredRowModel().rows.length — in manual/server filtering mode that equals data.length as given, since filtering already happened server-side.

First/last page buttons (paginationEdgeButton) hide automatically below 640px to save space on mobile — only prev/next remain.

Loading state

Pass loading to swap the body for skeleton rows in place:

<ProTable data={data} columns={columns} loading={isLoading} skeletonRowCount={8} />

For a full-page skeleton (including toolbar and pagination) before any table instance exists — e.g. during a route transition — use ProTableSkeleton directly:

<ProTableSkeleton
  columnCount={5}
  rowCount={8}
  withToolbar
  filterCount={3}
  columnWidths={['180px', 'auto', 'auto', 'auto', '120px']}
/>

Row expansion

<ProTable data={data} columns={columns} enableExpanding getSubRows={(row) => row.children} />

The first visible cell in each row becomes the expand/collapse toggle (chevron + indentation by row.depth * 20px) when row.getCanExpand() is true.

Cell renderers

A set of prebuilt cells for common column shapes, all in cells.ts:

cell: ({ row }) => (
  <PersonCell
    avatarUrl={row.original.avatarUrl}
    name={row.original.name}
    subtitle={row.original.email}
  />
)

Avatar + name, with the subtitle joined by a middot and truncated independently of the primary text.

By default, primary/secondary text in stacked cells truncates with an ellipsis. Set meta: { noEllipsis: true } on the column to let it wrap instead.

useProTable

The hook that assembles a TanStack Table instance with all ProTableFeatures enabled (sorting, pagination, expansion, column visibility, column sizing, row selection, column filtering). Every state slice is dual-mode: pass a controlled value + onChange, or omit both and useProTable manages it internally with useState.

const table = useProTable({
  data,
  columns,
  // controlled, e.g. server-side pagination:
  pagination,
  onPaginationChange: setPagination,
  manualPagination: true,
  rowCount: totalFromServer
})

Use the returned table instance directly with ProTable, ProTableToolbar, and ProTableSortList to keep them in sync.

proTableFeatures

The TanStack tableFeatures() bundle ProTable is built on — exported in case you need to type a table instance yourself (Table<ProTableFeatures, TData>, Column<ProTableFeatures, TData, TValue>, etc.) outside of useProTable.

export const proTableFeatures = tableFeatures({
  rowSortingFeature,
  rowPaginationFeature,
  rowExpandingFeature,
  columnVisibilityFeature,
  columnSizingFeature,
  rowSelectionFeature,
  columnFilteringFeature,
  sortedRowModel: createSortedRowModel(),
  paginatedRowModel: createPaginatedRowModel(),
  expandedRowModel: createExpandedRowModel(),
  filteredRowModel: createFilteredRowModel()
})

Known limitations

  • The range filter variant has no control implemented yet (needs a RangeSlider primitive).
  • showSelectedCount in ProTablePagination requires rowSelectionFeature to be wired up on your columns (a selection column with checkboxes) — it renders 0 of n otherwise.
  • showTotal and the filtered-count text can diverge under manual filtering, since the filtered count reflects what the server already returned rather than a true server-side total; use rowCount / getRowCount() for the real total in that mode.