@vstn-tech/data-table
v0.23.0
Published
React data grid built on TanStack Table + Mantine v9. Clean-room implementation by VSTN Tech.
Maintainers
Readme
@vstn-tech/data-table
A React 19 data grid built on TanStack Table v8, TanStack Virtual, Mantine v9, and dnd-kit. Clean-room implementation owned by VSTN Tech — zero derivation from upstream simple-table.
For AI agents (Claude Code, Cursor, etc.): the canonical usage manual is
AGENTS.md— it is self-contained, covers every prop / type / recipe, and is intentionally formatted so you can paste it into context.
Why this library
- Excel-like UX out of the box: one unified filter popover per column, cell-level selection with Shift/Ctrl-click range extension, arrow-key nav, Ctrl+C copy as TSV, F2/double-click to edit, Tab/Enter to navigate-and-commit.
- TanStack-native: filter state lives in
columnFilters, not a parallel store — you can read and write filters through the standard TanStack API. - Fast at 100k rows: row virtualization with variable-height measurement; cell selection store uses
useSyncExternalStoreso a click re-renders 1-2 cells, not the whole viewport. - Mantine themed: inherits theme via CSS variables from your
<MantineProvider>— no separate theme prop, no color overrides. - Headless or batteries-included:
useDataTable()for full headless control, or pass the table instance to<DataTable>for the default shell.
Install
pnpm add @vstn-tech/data-tablePeer dependencies your app must provide:
react >=19
react-dom >=19
@mantine/core >=9
@mantine/hooks >=9
@tanstack/react-table >=8.20
@tanstack/react-virtual >=3.10Optional (depending on features you use):
@mantine/dates >=9 # required for date filters / date cell editor
dayjs >=1.11
@dnd-kit/core >=6 # required for column reorder
@dnd-kit/sortable >=8
@dnd-kit/utilities >=3.2Wrap your app once:
import "@mantine/core/styles.css";
import "@mantine/dates/styles.css"; // only if you use date features
import "@vstn-tech/data-table/styles.css";
import { MantineProvider } from "@mantine/core";
export function App() {
return (
<MantineProvider>
{/* your app */}
</MantineProvider>
);
}GitHub Packages registry (for the published package):
# .npmrc
@vstn-tech:registry=https://npm.pkg.github.comQuick start
import {
DataTable,
createColumnHelper,
useDataTable,
} from "@vstn-tech/data-table";
interface Employee {
id: number;
name: string;
role: string;
salary: number;
joinedAt: string;
}
const columnHelper = createColumnHelper<Employee>();
const columns = [
columnHelper.accessor("id", {
header: "ID",
size: 80,
meta: { filterType: "number", align: "right" },
}),
columnHelper.accessor("name", {
header: "Name",
size: 240,
meta: { filterType: "text", isEditable: true, editor: "text" },
}),
columnHelper.accessor("role", {
header: "Role",
size: 180,
meta: { filterType: "set" },
}),
columnHelper.accessor("salary", {
header: "Salary",
size: 120,
meta: { filterType: "number", align: "right" },
}),
columnHelper.accessor("joinedAt", {
header: "Joined",
size: 140,
meta: { filterType: "date" },
}),
];
export function EmployeesPage({ data }: { data: Employee[] }) {
const table = useDataTable<Employee>({
data,
columns,
initialState: { sorting: [{ id: "salary", desc: true }] },
});
return (
<DataTable
table={table}
onRowClick={(emp) => console.log("clicked", emp)}
onCellEdit={({ rowId, columnId, newValue }) => {
console.log("commit", rowId, columnId, newValue);
}}
style={{ height: 600 }}
/>
);
}That's the entire integration. No drawer wiring, no per-page sortedRows useMemo, no parallel filter store.
Column meta
The TanStack ColumnDef is augmented with these meta fields:
| Field | Purpose |
|---|---|
| filterType | "text" \| "number" \| "date" \| "boolean" \| "set" — drives the filter dropdown UI. Omit to disable filtering for the column. |
| filterOptions | Predefined set-filter options. If omitted, options are computed from faceted unique values. |
| filterPlaceholder | Placeholder text for the filter input. |
| align | "left" \| "right" \| "center" — header + cell alignment. |
| ellipsis | Truncate body cell text with …. |
| pinned | "left" \| "right" — initial column pinning (CSS sticky). |
| isEditable | When true, F2 / double-click opens the inline editor. |
| editor | "text" \| "number" \| "boolean" \| "date" \| "enum" — which Mantine input to use. |
| isInternal | Hides the column from column-editor UIs (selection checkbox, action kebabs). |
Recipes
Filter UI
Each column with meta.filterType shows a ▾ icon in its header on hover. Click it to open a single Mantine <Popover> containing:
- Sort A → Z / Z → A / Clear sort
- Reset width / Hide column
- Filter by condition — type-specific inputs:
- Text: operator (contains / equals / startsWith / …) + value, optional second condition joined by AND/OR
- Number: comparator (= / > / between / …) + value(s)
- Date: explicit dates + relative ranges (today / last 7 days / this quarter / YTD / …)
- Boolean: True / False / Blank
- Set: faceted checklist with search and
(Blanks)row, virtualized for large lists
- Clear filter (when active)
Filters apply live (text inputs debounce 200ms). All filter state lives in TanStack's columnFilters — read or write it imperatively:
table.setColumnFilters([
{ id: "name", value: { type: "text", primary: { operator: "contains", value: "ada" } } },
{ id: "role", value: { type: "set", values: ["engineer", "manager"] } },
]);
table.getColumn("salary")?.setFilterValue({
type: "number",
primary: { operator: "between", value: 80000, value2: 150000 },
});Excel-like cell selection + clipboard
Enabled by default (enableCellSelection defaults to true):
- Click a cell → selects + activates
- Shift+click → extend rectangle from anchor
- Ctrl/Cmd+click → toggle individual cell
- Arrow keys → move active (Home / End / Ctrl+Home / Ctrl+End jump to edges)
- Shift+arrow → extend selection
- Ctrl+A → select all visible cells
- Ctrl+C → copy as TSV (paste lossless into Excel / Sheets)
- Esc → clear
The selection store is fine-grained: each cell subscribes only to its own selection state via useSyncExternalStore, so clicking one cell on a 100k-row table re-renders 1-2 cells, not the whole viewport.
Inline cell editing
Set meta.isEditable: true and meta.editor on a column, then handle the commit:
const columns = [
columnHelper.accessor("name", {
header: "Name",
meta: { isEditable: true, editor: "text" },
}),
columnHelper.accessor("status", {
header: "Status",
meta: { isEditable: true, editor: "enum", filterOptions: ["draft", "active", "archived"] },
}),
];
<DataTable
table={table}
onCellEdit={({ rowId, columnId, oldValue, newValue, row }) => {
// Update your data however you like (mutate state, fire a mutation, etc.).
updateEmployee(rowId, { [columnId]: newValue });
}}
/>- Double-click or press F2 to enter edit mode
- Enter commits and moves down (Shift+Enter → up)
- Tab commits and moves right (Shift+Tab → left)
- Esc cancels
- Blur outside the cell commits
Column pinning
Set meta.pinned: "left" | "right" for initial pinning; or call table.setColumnPinning(...) programmatically. Renders via CSS sticky positioning with a 1px edge shadow.
Column resize
On by default. Drag the right edge of any header to resize live (uses TanStack's columnResizeMode: "onChange"). Double-click the handle to reset.
Column reorder
Opt in by passing enableColumnReordering. A grip handle appears in each header on hover; drag a header to a new position.
<DataTable table={table} enableColumnReordering />Grouping + expansion
Pass state.grouping via initialState:
const table = useDataTable({
data,
columns,
initialState: { grouping: ["team", "status"] },
});Group cells get a ▶ / ▼ expand toggle and (N) count. Nested rows indent by depth.
Pagination
Show the Mantine pagination footer:
<DataTable
table={table}
showPagination
pageSizeOptions={[25, 50, 100, 250]}
/>Infinite scroll
Provide onLoadMore; the table fires it when the scroll reaches within infiniteScrollThreshold px of the bottom:
<DataTable
table={table}
onLoadMore={() => fetchNextPage()}
infiniteScrollThreshold={300}
loading={isFetchingNextPage}
/>Server-side mode
Switch into manual mode on the hook and feed pre-processed rows:
const table = useDataTable<Employee>({
data: pageData.items,
columns,
manualSorting: true,
manualFiltering: true,
manualPagination: true,
pageCount: pageData.totalPages,
onSortingChange: (updater) => {
const next = typeof updater === "function" ? updater(sorting) : updater;
setSorting(next);
refetch(next, filters, page);
},
onColumnFiltersChange: (updater) => {
const next = typeof updater === "function" ? updater(filters) : updater;
setFilters(next);
refetch(sorting, next, page);
},
onPaginationChange: (updater) => {
const next = typeof updater === "function" ? updater(page) : updater;
setPage(next);
refetch(sorting, filters, next);
},
});CSV export
import { exportToCSV } from "@vstn-tech/data-table";
<Button onClick={() => exportToCSV(table, { fileName: "employees.csv" })}>
Export CSV
</Button>CSV respects the current sort + filter + column visibility. Uses UTF-8 with BOM (Excel-compatible). Synchronous; suitable up to ~1M rows.
Loading / error / empty states
<DataTable
table={table}
loading={isFetching}
error={fetchError ? { message: fetchError.message, onRetry: refetch } : null}
emptyState={<MyCustomEmpty />}
/>Theming
The table reads colors from Mantine CSS variables — there is no theme prop. Switch your MantineProvider's colorScheme or primaryColor and the table follows.
CSS variables you can override on .dt-root:
.dt-root {
--dt-row-pad-y: 10px;
--dt-row-pad-x: 12px;
--dt-header-h: 36px;
}For compact density, pass density="compact" on <DataTable> — --dt-row-pad-y drops to 4px and --dt-header-h to 28px.
Migrating from ListTableV2
Most props map directly:
| ListTableV2 prop | data-table equivalent |
|---|---|
| state (from useListTableStateV2) | table (from useDataTable) |
| data | (already on useDataTable) |
| columns (with meta.filterType) | (same shape; meta is identical) |
| loading | loading |
| onRowClick | onRowClick |
| rowActions | (build your own column with a kebab cell renderer) |
| emptyState | emptyState |
| idAccessor | getRowId on useDataTable |
| pinnedFirstColumn | (use meta.pinned: "left" on the column) |
| applyFiltersToData={false} | manualFiltering: true on useDataTable |
| page / pageSize / totalRecords | manualPagination: true + pageCount + state.pagination on the hook; showPagination on the component |
| enableRowSelection | enableRowSelection on the hook |
| selectionToolbar | (render conditionally based on table.getSelectedRowModel()) |
The most substantial behavioral change: filter state now lives in table.getState().columnFilters and writes through column.setFilterValue / table.setColumnFilters — not a parallel state.setFilters. The "no-op setFilterValue" wart from V2 is fixed.
Public API
The library exports:
- Components:
DataTable,DataTablePagination,ActiveFiltersBar,EmptyState,LoadingOverlay,ErrorState,CellEditor,ColumnDropdown,ResizeHandle,DragHandle,SortableHeader - Hooks:
useDataTable,useIsCellSelected,useIsCellActive,useCellSelectionContext,useEditingContext,useInfiniteScroll - Helpers:
createColumnHelper,createCellSelectionStore,getPinningStyles,applyFilter,isFilterActive,dataTableFilterFn,computeFacets,buildSelectionTSV,copySelectionToClipboard,buildCSVString,exportToCSV - Types:
DataTableProps,DataTableColumn,DataTableColumnMeta,DataTableInstance,DataTableState,UseDataTableOptions,DataTableAPI,RowAction,Density,CellEditor(variant),CellAlignment,CellEditCallbackProps,FilterValue,FilterType,TextFilter,NumberFilter,DateFilter,BooleanFilter,SetFilter, and all operator/condition types - Constants:
CellSelectionContext,EditingContext,EditingProvider,OnCellEditContext
Known limitations / non-goals
- XLSX export is not yet bundled (CSV only). The TanStack column meta supports the same shape ListTableV2 used (
meta.excel); a worker-based XLSX exporter is on the roadmap behindimport "@vstn-tech/data-table/xlsx". - Sticky parent rows during scroll (group label pinning) — planned for a follow-up minor release.
- Column editor side-panel (Drawer) — currently consumers build their own using
table.setColumnVisibility,table.setColumnOrder,table.setColumnPinning. A pre-built<ColumnEditorDrawer>is on the roadmap. - Persistence to localStorage under
tableKey— planned; current API acceptstableKeybut does not yet wire storage.
Accessibility
aria-rowcounton the table;aria-rowindexon each rendered virtual row.- Active cell receives
tabIndex={0}+ focus; non-active cells are removed from the tab order. - Sort buttons announce direction via
aria-sort-equivalent visible indicators. - Filter popovers are Mantine-themed and follow Mantine's accessibility defaults.
- Keyboard navigation works without a mouse (Phase 4 work — the table is usable end-to-end via keyboard).
License
MIT — © VSTN Tech.
