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:
| File | Purpose |
|---|
DataTable.tsx | Main orchestrating component — state, layout, virtualizer |
DataTableColumnHeader.tsx | Sortable column header with dropdown |
DataTableFacetedFilter.tsx | Multi-select popover filter for toolbar layout |
FilterExplorerPanel.tsx | Left-panel explorer filter for explorer layout |
BulkActionToolbar.tsx | Selection 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}
/>
)
}
With text search
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.
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.
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.
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:
| Prop | Type | Description |
|---|
items | T[] | Data array |
height | number | Fixed container height in px |
estimateSize | number | Estimated item height (used for scroll math) |
renderItem | (item, index, virtualItem) => ReactNode | Item renderer |
overscan | number | Extra items rendered outside viewport (default: 5) |
measureElement | boolean | Enable dynamic measurement for variable-height items |
gap | number | Pixel gap between items |
loading | boolean | Show skeleton placeholders |
loadingItemCount | number | Number of skeletons when loading |
renderSkeleton | (index) => ReactNode | Custom skeleton renderer |
renderEmpty | () => ReactNode | Empty state renderer |
horizontal | boolean | Horizontal scrolling mode |
getItemKey | (item, index) => string | number | Stable 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),
]}
/>
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>[]
- 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'