Skip to main content

Documentation Index

Fetch the complete documentation index at: https://grantmaster.dev/llms.txt

Use this file to discover all available pages before exploring further.

Data Table Component Guide

This guide covers the data table system used throughout GrantMaster, built on @tanstack/react-table v8 with @tanstack/react-virtual for virtualization.

Architecture Overview

The data table system lives in src/components/ui/data-table/ and consists of five files:
FilePurpose
DataTable.tsxMain orchestrating component — state, layout, virtualizer
DataTableColumnHeader.tsxSortable column header with dropdown
DataTableFacetedFilter.tsxMulti-select popover filter for toolbar layout
FilterExplorerPanel.tsxLeft-panel explorer filter for explorer layout
BulkActionToolbar.tsxSelection count bar + bulk action buttons

Dependency stack

@tanstack/react-table   — headless table logic (sorting, filtering, column model)
@tanstack/react-virtual — row virtualizer (replaces react-window in this codebase)
@dnd-kit/core + sortable — column drag-and-drop reordering
The DataTable component wires these libraries together so consumers only need to supply columns and data. All internal state (sorting, filters, visibility, column order, sizing, row selection) is managed inside DataTable.

Column Definition Patterns

Column definitions use ColumnDef<TData, TValue> from @tanstack/react-table. Define them in a dedicated columns.tsx file co-located with the feature.

Basic accessor column

import { ColumnDef } from '@tanstack/react-table'
import { DataTableColumnHeader } from '@/components/ui/data-table/DataTableColumnHeader'

export const columns: ColumnDef<MyRecord>[] = [
  {
    accessorKey: 'name',
    header: ({ column }) => (
      <DataTableColumnHeader column={column} title="Name" />
    ),
    cell: ({ row }) => (
      <span className="text-sm font-medium text-slate-900 dark:text-white">
        {row.getValue('name')}
      </span>
    ),
    size: 200,
  },
]
Always use DataTableColumnHeader for sortable columns. It renders an ArrowUp/ArrowDown/ChevronsUpDown icon and exposes a sort/hide dropdown.

Computed (id-based) column

Use id instead of accessorKey when the cell value is derived from multiple fields or requires custom logic:
{
  id: 'status',
  header: ({ column }) => (
    <DataTableColumnHeader column={column} title="Status" />
  ),
  cell: ({ row }) => {
    const record = row.original
    return <StatusBadge status={record.status} />
  },
  sortingFn: (rowA, rowB) => {
    return rowA.original.status.localeCompare(rowB.original.status)
  },
  size: 120,
},

Column with faceted filtering

To enable faceted (multi-select) filtering on a column, add filterFn:
{
  accessorKey: 'category',
  header: ({ column }) => (
    <DataTableColumnHeader column={column} title="Category" />
  ),
  cell: ({ row }) => <span>{row.getValue('category')}</span>,
  filterFn: (row, id, value) => {
    // value is string[] — the selected filter values
    return value.includes(row.getValue(id))
  },
  size: 140,
},
For array-valued fields (e.g. a record that belongs to multiple categories), use some:
filterFn: (row, id, value) => {
  const rowValue = row.getValue(id) as string[]
  if (!rowValue) return false
  return rowValue.some((val) => value.includes(val))
},

Column sizing

Set size on every column. The table uses tableLayout: fixed and getSize() on each column, so explicit sizes are required for consistent layout. The default is 150px if omitted.
{ accessorKey: 'id',          size: 80,  enableHiding: false }
{ accessorKey: 'title',       size: 240 }
{ accessorKey: 'status',      size: 120 }
{ accessorKey: 'description', size: 320 }

Disabling sort / hide / resize

{
  accessorKey: 'actions',
  header: 'Actions',
  enableSorting: false,
  enableHiding: false,
  enableResizing: false,
  size: 80,
}

Using the DataTable Component

Minimal usage

import { DataTable } from '@/components/ui/data-table/DataTable'
import { columns } from './columns'

function MyList({ records }: { records: MyRecord[] }) {
  return (
    <DataTable
      columns={columns}
      data={records}
    />
  )
}
Pass searchKey matching an accessorKey in your columns. A text input will appear in the toolbar and filter that column:
<DataTable
  columns={columns}
  data={records}
  searchKey="name"
/>

With row click navigation

import { useNavigate } from 'react-router-dom'

const navigate = useNavigate()

<DataTable
  columns={columns}
  data={grants}
  onRowClick={(grant) => navigate(`/grants/${grant.id}`)}
/>

Full DataTable props reference

interface DataTableProps<TData, TValue> {
  columns: ColumnDef<TData, TValue>[]
  data: TData[]
  /** accessorKey to filter via the toolbar text input */
  searchKey?: string
  /** Handler called when a row is clicked */
  onRowClick?: (row: TData) => void
  /** Faceted filter definitions for toolbar or explorer panel */
  facetedFilters?: FacetedFilterDef[]
  /** Show the left-panel explorer layout instead of inline toolbar filters */
  useExplorerLayout?: boolean
  /** Ref to a div where the Columns dropdown will be rendered (portal) */
  externalToolbarContainer?: React.RefObject<HTMLDivElement>
  /** Prepend a checkbox column for row selection */
  enableRowSelection?: boolean
  /** Actions to show in the BulkActionToolbar when rows are selected */
  bulkActions?: BulkAction<TData>[]
  /** Called whenever the selection changes */
  onSelectionChange?: (selectedRows: TData[]) => void
}

Sorting

Sorting is client-side and enabled by default on all columns. The DataTable manages SortingState internally. DataTableColumnHeader renders the sort control. Clicking it opens a dropdown with Asc / Desc / Hide options. To disable sorting on a column:
{ accessorKey: 'icon', enableSorting: false }
To provide a custom sort function (e.g. for computed or enum columns):
{
  id: 'priority',
  sortingFn: (rowA, rowB) => {
    const order = { high: 0, medium: 1, low: 2 }
    return order[rowA.original.priority] - order[rowB.original.priority]
  },
}
The table uses getSortedRowModel() from @tanstack/react-table, so all sorting happens in-memory on the current data array.

Filtering

Two filter surfaces are available depending on the use case.

Toolbar faceted filters (default layout)

Use this for tables with a small number of filter categories (typically 2-5). Filters appear as popover buttons in the toolbar above the table.
const facetedFilters = [
  {
    columnId: 'status',
    title: 'Status',
    options: [
      { label: 'Active',   value: 'active' },
      { label: 'Draft',    value: 'draft' },
      { label: 'Archived', value: 'archived' },
    ],
  },
  {
    columnId: 'category',
    title: 'Category',
    options: categories.map(c => ({ label: c.name, value: c.id })),
  },
]

<DataTable
  columns={columns}
  data={records}
  facetedFilters={facetedFilters}
/>
Each filter option also shows a facet count (number of rows matching that value) sourced from getFacetedUniqueValues().

Explorer layout (left-panel filters)

Use this for tables with many filter groups (5+) or when filter discoverability is important. A resizable left panel replaces the inline toolbar filters.
<DataTable
  columns={columns}
  data={records}
  facetedFilters={facetedFilters}
  useExplorerLayout
/>
The explorer panel:
  • Renders collapsible filter groups (all collapsed by default)
  • Supports panel resize via drag handle (180px–600px range)
  • Shows active filter count badge on the panel header
  • Has a “Clear all filters” shortcut when any filter is active
  • Auto-calculates an optimal initial width based on the longest option label

Combining text search and faceted filters

Both can be used together:
<DataTable
  columns={columns}
  data={records}
  searchKey="title"
  facetedFilters={facetedFilters}
/>
The text search filters one column; faceted filters accumulate across their respective columns. A “Reset” button appears when any filter is active.

Pagination

The DataTable component does not implement pagination. All filtering, sorting, and display happen on the full in-memory data array. For large datasets, virtual scrolling handles performance (see below). If server-side pagination is required for a future use case, the @tanstack/react-table getPaginationRowModel() and manual pagination APIs are available but not currently wired up. A row count line is rendered below the table:
  • No selection active: {n} total row(s)
  • Rows selected: {k} of {n} row(s) selected

Column Features

Column reordering (drag and drop)

All columns are draggable by default using @dnd-kit. A grip icon appears on hover in each header cell. The internal columnOrder state is updated via arrayMove on drag end. No configuration is needed — this is always enabled.

Column resizing

Columns are resizable via the resize handle at the right edge of each header cell. The handle is invisible by default and becomes visible on hover (showing a primary-colored stripe). Resize mode is onChange — the column width updates live as the user drags. To disable resizing on a specific column:
{ accessorKey: 'select', enableResizing: false }

Column visibility

A “Columns” dropdown button appears in the toolbar (top right). It lists all hideable columns with checkboxes. To prevent a column from appearing in the dropdown:
{ accessorKey: 'id', enableHiding: false }
The Columns dropdown can be rendered into an external container (e.g. a page-level toolbar) using the externalToolbarContainer prop:
const toolbarRef = React.useRef<HTMLDivElement>(null)

// In your page toolbar:
<div ref={toolbarRef} />

// On the DataTable:
<DataTable
  columns={columns}
  data={data}
  externalToolbarContainer={toolbarRef}
/>

Row Selection and Bulk Actions

Enable row selection to allow users to select multiple rows and perform bulk operations.
import {
  BulkAction,
  createDeleteAction,
  createExportAction,
} from '@/components/ui/data-table/BulkActionToolbar'

const bulkActions: BulkAction<MyRecord>[] = [
  createDeleteAction(async (rows) => {
    await deleteRecords(rows.map(r => r.id))
  }),
  createExportAction((rows) => {
    exportToCSV(rows)
  }),
  // Custom action:
  {
    id: 'approve',
    label: 'Approve',
    icon: CheckCircle,
    variant: 'default',
    onClick: async (rows) => {
      await approveAll(rows.map(r => r.id))
    },
  },
]

<DataTable
  columns={columns}
  data={records}
  enableRowSelection
  bulkActions={bulkActions}
  onSelectionChange={(selected) => console.log(selected)}
/>
When rows are selected, BulkActionToolbar renders above the table showing the selection count and action buttons. Row selection is cleared automatically when data changes (e.g. after a delete mutation). The _select column (checkbox) is prepended automatically when enableRowSelection is true. It is not sortable, not hideable, and not resizable.

Virtual Scrolling for Large Datasets

Automatic virtualization

The DataTable automatically enables row virtualization when data.length > 100. No configuration is needed:
// Works the same for 10 rows or 10,000 rows
<DataTable columns={columns} data={largeDataset} />
Internally, DataTable uses useVirtualizer from @tanstack/react-virtual:
const ROW_HEIGHT = 48  // px — fixed row height assumed

const rowVirtualizer = useVirtualizer({
  count: rows.length,
  getScrollElement: () => scrollContainerRef.current,
  estimateSize: () => ROW_HEIGHT,
  overscan: 5,
  enabled: virtualize && rows.length > 0,
})
The table container has max-h-[800px] with overflow-auto. The virtualizer renders spacer <tr> elements above and below visible rows to maintain correct scroll dimensions.

VirtualList for non-table lists

For virtualized card lists or custom list layouts (not table grid), use VirtualList from @/components/ui/VirtualList:
import { VirtualList } from '@/components/ui/VirtualList'

<VirtualList
  items={grants}
  height={600}
  estimateSize={88}
  renderItem={(grant, index) => (
    <GrantCard key={grant.id} grant={grant} />
  )}
/>
VirtualList props:
PropTypeDescription
itemsT[]Data array
heightnumberFixed container height in px
estimateSizenumberEstimated item height (used for scroll math)
renderItem(item, index, virtualItem) => ReactNodeItem renderer
overscannumberExtra items rendered outside viewport (default: 5)
measureElementbooleanEnable dynamic measurement for variable-height items
gapnumberPixel gap between items
loadingbooleanShow skeleton placeholders
loadingItemCountnumberNumber of skeletons when loading
renderSkeleton(index) => ReactNodeCustom skeleton renderer
renderEmpty() => ReactNodeEmpty state renderer
horizontalbooleanHorizontal scrolling mode
getItemKey(item, index) => string | numberStable key extractor
For advanced use cases, useVirtualList hook is also exported:
import { useVirtualList } from '@/components/ui/VirtualList'

const { virtualizer, parentRef } = useVirtualList({
  count: items.length,
  estimateSize: 56,
  overscan: 10,
})

Common Usage Patterns

Pattern 1: Feature list page

The standard pattern for a feature list with sorting, filtering, and row click navigation:
// features/expenses/components/ExpenseList.tsx
import { DataTable } from '@/components/ui/data-table/DataTable'
import { columns } from './expense-table/columns'

export function ExpenseList() {
  const { expenses, isLoading } = useExpenses()
  const navigate = useNavigate()

  if (isLoading) return <LoadingSpinner />

  return (
    <DataTable
      columns={columns}
      data={expenses}
      searchKey="description"
      facetedFilters={[
        {
          columnId: 'status',
          title: 'Status',
          options: EXPENSE_STATUS_OPTIONS,
        },
      ]}
      onRowClick={(expense) => navigate(`/expenses/${expense.id}`)}
    />
  )
}

Pattern 2: Admin view with explorer layout and bulk actions

<DataTable
  columns={columns}
  data={policies}
  facetedFilters={facetedFilters}
  useExplorerLayout
  enableRowSelection
  bulkActions={[
    createDeleteAction(handleBulkDelete),
    createExportAction(handleExport),
  ]}
/>

Pattern 3: External toolbar integration

For pages that render the Columns toggle inside a PageHeader actions area rather than inside the table toolbar:
function GrantsPage() {
  const toolbarActionsRef = React.useRef<HTMLDivElement>(null)

  return (
    <PageLayout>
      <PageHeader
        title="Grants"
        actions={
          <div className="flex gap-2">
            <Button onClick={handleCreate}>New Grant</Button>
            {/* DataTable will portal the Columns dropdown here */}
            <div ref={toolbarActionsRef} />
          </div>
        }
      />
      <DataTable
        columns={columns}
        data={grants}
        externalToolbarContainer={toolbarActionsRef}
      />
    </PageLayout>
  )
}

Pattern 4: Column file structure

Keep column definitions in a *-table/columns.tsx file alongside the component that uses the table:
features/expenses/
├── components/
│   ├── ExpenseList.tsx          ← uses DataTable
│   └── expense-table/
│       └── columns.tsx          ← ColumnDef<Expense>[]

Performance Notes

  • Virtualization kicks in automatically at 101+ rows with no extra configuration.
  • Fixed ROW_HEIGHT = 48px is assumed. If rows have variable heights (e.g. multi-line cells with line-clamp-2), the virtual scroll position can drift slightly — this is acceptable for typical list views.
  • Column sizing uses tableLayout: fixed. Every column must have a size set or the browser will distribute space unevenly.
  • The @dnd-kit drag activation distance is set to 8px to prevent accidental drags during row clicks.
  • getFacetedRowModel() and getFacetedUniqueValues() are always enabled, which means facet counts are computed for all filterable columns on every render. Avoid more than ~20 distinct filter options per column on datasets exceeding 5,000 rows.

Imports Reference

// Main table component
import { DataTable } from '@/components/ui/data-table/DataTable'

// Sortable column header
import { DataTableColumnHeader } from '@/components/ui/data-table/DataTableColumnHeader'

// Bulk action types and factory helpers
import {
  BulkAction,
  BulkActionToolbar,
  createDeleteAction,
  createExportAction,
} from '@/components/ui/data-table/BulkActionToolbar'

// For custom virtualised list (non-table)
import { VirtualList, useVirtualList } from '@/components/ui/VirtualList'

// @tanstack/react-table types used in column definitions
import { ColumnDef } from '@tanstack/react-table'