Batteries-included TanStack Table wrapper for React. TypeScript-first, headless, zero UI lock-in.
Go from ~100 lines of TanStack Table boilerplate to ~10 lines. No CSS. No component library. Full escape hatch to the raw table instance.
npm i @marvinackerman/tablecraft @tanstack/react-table
import { useTable, createColumns } from '@marvinackerman/tablecraft'
import { flexRender } from '@tanstack/react-table'
type User = { id: number; name: string; email: string; role: string }
const columns = createColumns<User>([
{ accessorKey: 'name', header: 'Name' },
{ accessorKey: 'email', header: 'Email' },
{ accessorKey: 'role', header: 'Role' },
])
function UsersTable({ users }: { users: User[] }) {
const { table, pagination, sorting, globalFilter } = useTable({
data: users,
columns,
pagination: { pageSize: 20 },
sorting: true,
globalFilter: true,
})
return (
<div>
<input
placeholder="Search..."
value={globalFilter.value}
onChange={(e) => globalFilter.setValue(e.target.value)}
/>
<table>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id} onClick={header.column.getToggleSortingHandler()}>
{flexRender(header.column.columnDef.header, header.getContext())}
{{ asc: ' ↑', desc: ' ↓' }[header.column.getIsSorted() as string] ?? ''}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
<div>
<button onClick={pagination.previousPage} disabled={!pagination.canPreviousPage}>Previous</button>
<span>Page {pagination.pageIndex + 1} of {pagination.pageCount}</span>
<button onClick={pagination.nextPage} disabled={!pagination.canNextPage}>Next</button>
</div>
</div>
)
}Sorting, pagination, global search, full TypeScript generics — wired up in one call.
@tanstack/react-table is intentionally 100% headless. That's its strength, but every project starts with 80–150 lines of identical boilerplate: useState for sorting, useState for pagination, useState for filters, useMemo for columns, manual row model wiring…
tablecraft eliminates the boilerplate without taking away control. You get sensible defaults, and the full TanStack Table instance is always available as an escape hatch.
| Feature | TanStack Table | AG Grid | Material React Table | tablecraft |
|---|---|---|---|---|
| Headless (no CSS) | Yes | No | No | Yes |
| Zero boilerplate | No | Yes | Yes | Yes |
| TypeScript-first | Yes | Yes | Yes | Yes |
| State persistence | No | Enterprise $$ | No | Free |
| Inline editing | No | Enterprise $$ | No | Free |
| Bundle size | ~15 KB | ~300 KB | ~50 KB | ~21 KB (tablecraft + TanStack) |
| License | MIT | MIT* | MIT | MIT |
The single hook that covers 90% of use cases. Every option is optional.
const {
table, // Full TanStack Table<TData> instance
pagination,
sorting,
globalFilter,
columnFilters,
rowSelection,
columnVisibility,
rowExpansion,
grouping,
emptyState,
} = useTable({
data,
columns,
// Pagination
pagination: { pageSize: 20 }, // or true (defaults) or false (disabled)
// Sorting
sorting: { defaultSort: [{ id: 'name', desc: false }] }, // or true or false
// Filtering
globalFilter: true,
columnFilters: true,
// Row selection
rowSelection: true, // or { enableMultiRowSelection: false }
// Column visibility
columnVisibility: { defaultVisibility: { email: false } }, // or true
// Row expansion (nested sub-rows)
rowExpansion: { getSubRows: (row) => row.children }, // or true
// Row grouping
grouping: { defaultGrouping: ['role'] }, // or true
// Fuzzy search (requires match-sorter)
fuzzy: true,
// State persistence
persist: 'localStorage', // or 'sessionStorage'
persistKey: 'my-table',
persistOptions: { sorting: true, pagination: true },
// URL state sync
syncUrl: true, // or { keys: { page: 'p', sort: 's' }, mode: 'replace' }
})pagination return:
| Property | Type | Description |
|---|---|---|
pageIndex |
number |
Current page (0-indexed) |
pageSize |
number |
Rows per page |
pageCount |
number |
Total pages |
canPreviousPage |
boolean |
Can go back |
canNextPage |
boolean |
Can go forward |
previousPage |
() => void |
Go to previous page |
nextPage |
() => void |
Go to next page |
setPageIndex |
(index: number) => void |
Jump to page |
setPageSize |
(size: number) => void |
Change page size |
sorting return:
| Property | Type | Description |
|---|---|---|
sortingState |
SortingState |
Current sort state |
setSorting |
OnChangeFn<SortingState> |
Set sort state directly |
clearSorting |
() => void |
Clear all sorting |
rowSelection return:
| Property | Type | Description |
|---|---|---|
state |
RowSelectionState |
Current selection map |
toggleRow |
(rowId: string) => void |
Toggle a row |
toggleAll |
() => void |
Select / deselect all |
clearSelection |
() => void |
Clear all |
selectedRowIds |
string[] |
IDs of selected rows |
selectedCount |
number |
Number selected |
isSelected |
(rowId: string) => boolean |
Check if selected |
rowExpansion return:
| Property | Type | Description |
|---|---|---|
state |
ExpandedState |
Current expanded rows |
toggleRow |
(rowId: string) => void |
Toggle expand |
expandRow |
(rowId: string) => void |
Expand a row |
collapseRow |
(rowId: string) => void |
Collapse a row |
clearExpansion |
() => void |
Collapse all |
expandedRowIds |
string[] |
IDs of expanded rows |
isExpanded |
(rowId: string) => boolean |
Check if expanded |
grouping return:
| Property | Type | Description |
|---|---|---|
state |
GroupingState |
Current grouped columns |
toggleGrouping |
(columnId: string) => void |
Toggle group by column |
setGrouping |
(cols: GroupingState) => void |
Set grouping directly |
clearGrouping |
() => void |
Remove all grouping |
isGrouped |
(columnId: string) => boolean |
Check if grouped |
groupedColumns |
string[] |
Currently grouped column IDs |
emptyState return:
| Property | Type | Description |
|---|---|---|
isEmpty |
boolean |
True when data has 0 rows total |
isFilteredEmpty |
boolean |
True when filters return 0 rows |
Type-safe column definitions without useMemo or manual type annotations.
import { createColumns } from '@marvinackerman/tablecraft'
const columns = createColumns<User>([
{ accessorKey: 'name', header: 'Name', enableSorting: true },
{ accessorKey: 'email', header: 'Email' },
{
id: 'actions',
header: 'Actions',
cell: ({ row }) => <button onClick={() => editable.startEditing(row.id)}>Edit</button>,
},
])Automatically infers column definitions from your data shape.
import { inferColumns } from '@marvinackerman/tablecraft'
const columns = inferColumns(users, {
exclude: ['id'],
headers: { name: 'Full Name', role: 'Role' },
})Forces manualPagination and manualSorting to true. Use when your backend owns sorting, filtering, and pagination.
import { useServerTable } from '@marvinackerman/tablecraft'
const { table, pagination, sorting } = useServerTable({
data: serverData, // current page from your API
columns,
rowCount: totalRows, // required — total rows in the dataset
pagination: { pageSize: 20 },
onPaginationChange: (updater) => {
const next = typeof updater === 'function' ? updater(currentState) : updater
refetch({ page: next.pageIndex, pageSize: next.pageSize })
},
onSortingChange: (updater) => {
const next = typeof updater === 'function' ? updater(currentSort) : updater
refetch({ sort: next })
},
})Combines useServerTable with TanStack Query. Automatically re-fetches when sort, page, or filter state changes.
import { useQueryTable } from '@marvinackerman/tablecraft'
const { table, pagination, sorting, query } = useQueryTable({
queryKey: ['users'],
queryFn: async ({ pagination, sorting, globalFilter, columnFilters }) => {
const res = await api.getUsers({
page: pagination.pageIndex,
pageSize: pagination.pageSize,
sort: sorting,
search: globalFilter,
})
return { data: res.data, rowCount: res.total }
},
columns,
pagination: { pageSize: 20 },
sorting: true,
globalFilter: true,
})query is the raw TanStack Query result (isLoading, isError, isFetching, error, refetch, etc.).
Requires @tanstack/react-query to be installed:
npm i @tanstack/react-query
Cursor-based infinite scroll powered by TanStack Query's useInfiniteQuery. Pages are accumulated in a flat list — no pagination controls needed. When sort or filter state changes, accumulated pages automatically reset to page 1 via query key composition.
import { useInfiniteTable } from '@marvinackerman/tablecraft'
const { table, loadMore, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteTable({
queryKey: ['users'],
initialPageParam: null as string | null, // TCursor inferred as string | null
queryFn: async ({ pageParam, sorting, globalFilter }) => {
const res = await api.getUsers({
cursor: pageParam, // pageParam: string | null — fully typed ✓
sort: sorting,
search: globalFilter,
})
return {
data: res.data,
nextCursor: res.nextCursor, // string | null | undefined — typed ✓
}
},
columns,
sorting: true,
globalFilter: true,
})
// In your JSX:
<button onClick={loadMore} disabled={!hasNextPage || isFetchingNextPage}>
{isFetchingNextPage ? 'Loading…' : 'Load more'}
</button>All rows across every loaded page are available in table.getRowModel().rows as a single flat list — no page tracking on your end.
queryFn context:
| Property | Type | Description |
|---|---|---|
pageParam |
unknown |
Cursor/offset passed to your API. Type is controlled by your nextCursor return value |
sorting |
SortingState |
Current sort |
columnFilters |
ColumnFiltersState |
Current column filter values |
globalFilter |
string |
Current global search string |
grouping |
GroupingState |
Current grouping columns |
Return:
| Property | Type | Description |
|---|---|---|
table |
Table<TData> |
Full TanStack Table instance with all accumulated rows |
loadMore |
() => void |
Fetch the next page |
hasNextPage |
boolean |
true when nextCursor was returned from the last page |
isFetchingNextPage |
boolean |
true while a loadMore call is in-flight |
isLoading |
boolean |
true on the very first fetch |
isError |
boolean |
true if the query threw |
error |
Error | null |
The thrown error, if any |
refetch |
() => void |
Re-run the query from the beginning |
sorting |
SortingReturn |
Same shape as useTable |
globalFilter |
GlobalFilterReturn |
Same shape as useTable |
columnFilters |
ColumnFiltersReturn |
Same shape as useTable |
rowSelection |
RowSelectionReturn |
Opt-in — pass rowSelection: true |
columnVisibility |
ColumnVisibilityReturn |
Opt-in — pass columnVisibility: true |
grouping |
GroupingReturn |
Opt-in — pass grouping: true |
emptyState |
EmptyStateReturn |
Same shape as useTable |
Options:
| Option | Type | Default | Description |
|---|---|---|---|
queryKey |
unknown[] |
required | TanStack Query cache key |
queryFn |
(ctx) => Promise<{ data, nextCursor? }> |
required | Fetcher — return nextCursor: undefined to signal last page |
columns |
ColumnDef[] |
required | Column definitions |
initialPageParam |
unknown |
0 |
First value passed as pageParam |
sorting |
boolean | SortingOptions |
true |
Enable sorting |
globalFilter |
boolean |
true |
Enable global search |
columnFilters |
boolean |
true |
Enable column filters |
rowSelection |
boolean | RowSelectionOptions |
false |
Enable row selection |
columnVisibility |
boolean | ColumnVisibilityOptions |
false |
Enable column visibility |
grouping |
boolean | GroupingOptions |
false |
Enable row grouping |
staleTime |
number |
— | TanStack Query staleTime |
gcTime |
number |
— | TanStack Query gcTime |
enabled |
boolean |
— | TanStack Query enabled |
Requires @tanstack/react-query:
npm i @tanstack/react-query
Pairs with useInfiniteTable to trigger loadMore automatically when a sentinel element enters the viewport. Handles observer cleanup, reconnect, and stale-ref prevention internally.
import { useInfiniteScroll } from '@marvinackerman/tablecraft'
const { table, loadMore, hasNextPage, isFetchingNextPage } = useInfiniteTable({ ... })
const sentinelRef = useInfiniteScroll(loadMore, {
enabled: hasNextPage && !isFetchingNextPage, // never double-fires
})
return (
<>
<table>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id}>...</tr>
))}
</tbody>
</table>
{/* Place sentinel below the last row — fires loadMore as it scrolls into view */}
<div ref={sentinelRef} />
{isFetchingNextPage && <p>Loading more…</p>}
</>
)Options:
| Option | Type | Default | Description |
|---|---|---|---|
enabled |
boolean |
true |
Pass hasNextPage && !isFetchingNextPage to prevent double-firing |
rootMargin |
string |
'0px' |
Load ahead of scroll position — e.g. '200px' triggers before the sentinel is fully visible |
Standalone opt-in hook. Returns prop-getter objects to spread onto your table elements, implementing the WAI-ARIA Grid pattern.
import { useTableA11y } from '@marvinackerman/tablecraft'
const { table } = useTable({ data, columns })
const a11y = useTableA11y(table, {
selectionEnabled: true, // adds aria-selected to row props
})
// Spread onto your elements:
<table {...a11y.getTableProps()} />
// role="grid", aria-rowcount, aria-colcount
<th {...a11y.getHeaderProps(header.id)} />
// role="columnheader", aria-sort="ascending"|"descending"|"none"
<tr {...a11y.getRowProps(row.id)} />
// role="row", aria-rowindex, tabIndex, onKeyDown (ArrowUp/Down/Home/End)
// aria-selected (when selectionEnabled), aria-expanded (when expandable)
<td {...a11y.getCellProps(cellIndex)} />
// role="gridcell", aria-colindex
// Current focused row index (for custom focus ring styling):
a11y.focusedRowIndex // number | nullKeyboard navigation (applied automatically via onKeyDown on row props):
| Key | Action |
|---|---|
ArrowDown |
Move focus to next row |
ArrowUp |
Move focus to previous row |
Home |
Move focus to first row |
End |
Move focus to last row |
Enter / Space |
Toggle row selection (when selectionEnabled) |
Standalone opt-in hook for single-row inline editing. No form library required — works with Zod, Yup, or plain validation.
import { useEditableRows } from '@marvinackerman/tablecraft'
const { table } = useTable({ data, columns })
const editable = useEditableRows(table, {
onSave: async (rowId, draft) => {
// Return an error map to show validation errors and stay in edit mode
if (!draft.name) return { name: 'Name is required' }
// Or use any schema library
const result = schema.safeParse(draft)
if (!result.success) return formatErrors(result.error)
// Return nothing (or undefined) to commit and exit edit mode
await api.updateUser(rowId, draft)
},
})Return:
| Property | Type | Description |
|---|---|---|
editingRowId |
string | null |
Which row is being edited |
draftData |
Partial<TData> |
Current draft field values |
isDirty |
boolean |
Whether any field has changed |
dirtyFields |
(keyof TData)[] |
Which fields changed |
errors |
Partial<Record<keyof TData, string>> |
Field-level validation errors |
isSaving |
boolean |
True while onSave promise is in flight |
isEditing |
(rowId: string) => boolean |
Check if a row is in edit mode |
startEditing |
(rowId: string) => void |
Enter edit mode (snapshots original) |
setField |
(field, value) => void |
Update a draft field |
saveEditing |
() => Promise<void> |
Run onSave, commit or show errors |
cancelEditing |
() => void |
Discard changes, exit edit mode |
Usage in rows:
{table.getRowModel().rows.map((row) => (
<tr key={row.id}>
{editable.isEditing(row.id) ? (
<>
<td>
<input
value={editable.draftData.name ?? ''}
onChange={(e) => editable.setField('name', e.target.value)}
/>
{editable.errors.name && <span>{editable.errors.name}</span>}
</td>
<td>
<button onClick={editable.saveEditing}>Save</button>
<button onClick={editable.cancelEditing}>Cancel</button>
</td>
</>
) : (
<>
<td>{row.original.name}</td>
<td>
<button onClick={() => editable.startEditing(row.id)}>Edit</button>
</td>
</>
)}
</tr>
))}Set defaults for all tables in your app. Per-call options always override provider defaults.
import { TableKitProvider } from '@marvinackerman/tablecraft'
function App() {
return (
<TableKitProvider
defaults={{
pageSize: 25,
sorting: true,
globalFilter: true,
persist: 'localStorage',
}}
>
<YourApp />
</TableKitProvider>
)
}Persist table state across page reloads via localStorage or sessionStorage.
const { table } = useTable({
data,
columns,
persist: 'localStorage',
persistKey: 'users-table', // unique key per table
persistOptions: {
sorting: true,
pagination: true,
globalFilter: false,
columnFilters: false,
},
})Utilities for manual control:
import { savePersistedState, loadPersistedState, clearPersistedState } from '@marvinackerman/tablecraft'
savePersistedState('my-key', state, 'localStorage')
loadPersistedState('my-key', 'localStorage')
clearPersistedState('my-key', 'localStorage')Sync table state (page, sort, filters) to the URL. Works with any router.
const { table } = useTable({
data,
columns,
syncUrl: true,
// or with custom keys:
syncUrl: {
keys: { page: 'p', pageSize: 'ps', sort: 's', filter: 'q' },
mode: 'replace', // or 'push' (adds browser history entry)
},
})For advanced setups where you compose your own useReactTable call:
import {
useSortState,
usePaginationState,
useFilterState,
useColumnFilterState,
useRowSelectionState,
useColumnVisibilityState,
useRowExpansionState,
useGroupingState,
useColumnPinningState,
} from '@marvinackerman/tablecraft'
const sorting = useSortState({ defaultSort: [{ id: 'createdAt', desc: true }] })
const pagination = usePaginationState({ pageSize: 25 })
const globalFilter = useFilterState()
const columnFilters = useColumnFilterState()
const rowSelection = useRowSelectionState()
const columnVisibility = useColumnVisibilityState({ defaultVisibility: { id: false } })
const rowExpansion = useRowExpansionState({ allowMultiple: false })
const grouping = useGroupingState({ defaultGrouping: ['department'] })Each hook returns state + setters compatible with TanStack Table's state and on*Change props.
Pin columns to the left or right edge. TanStack Table provides the pixel offsets — your CSS does the sticking.
const { table, columnPinning } = useTable({
data,
columns,
columnPinning: true,
// or: columnPinning: { defaultPinning: { left: ['id'] } }
})
// Actions
columnPinning.pinLeft('name')
columnPinning.pinRight('email')
columnPinning.unpin('name')
columnPinning.clearPinning()
columnPinning.isPinned('name') // → 'left' | 'right' | false
columnPinning.leftColumns // → ['id']
columnPinning.rightColumns // → ['email']
// Render with sticky CSS
{table.getHeaderGroups().map(headerGroup => (
<tr key={headerGroup.id}>
{(['left', 'center', 'right'] as const).flatMap(position =>
(position === 'left'
? table.getLeftLeafHeaders()
: position === 'right'
? table.getRightLeafHeaders()
: table.getCenterLeafHeaders()
).map(header => (
<th
key={header.id}
style={{
position: header.column.getIsPinned() ? 'sticky' : 'relative',
left: header.column.getIsPinned() === 'left'
? `${header.column.getStart('left')}px`
: undefined,
right: header.column.getIsPinned() === 'right'
? `${header.column.getAfter('right')}px`
: undefined,
zIndex: header.column.getIsPinned() ? 1 : 0,
background: 'white',
}}
>
{flexRender(header.column.columnDef.header, header.getContext())}
</th>
))
)}
</tr>
))}
position: stickyis all CSS. Tablecraft provides the state and pixel offsets (getStart,getAfter). Your styles do the rest — no UI lock-in.
Also works in useQueryTable and useInfiniteTable with the same columnPinning option.
Options
| Option | Type | Default | Description |
|---|---|---|---|
columnPinning |
boolean | ColumnPinningOptions |
false |
Opt-in column pinning. Pass true to enable with defaults, or an object to configure. |
ColumnPinningOptions properties:
| Property | Type | Default | Description |
|---|---|---|---|
defaultPinning |
{ left?: string[], right?: string[] } |
{} |
Columns pinned on mount |
columnPinning return
| Property | Type | Description |
|---|---|---|
state |
ColumnPinningState |
Raw TanStack state |
pinLeft(id) |
fn |
Pin column to left edge |
pinRight(id) |
fn |
Pin column to right edge |
unpin(id) |
fn |
Remove pin from column |
clearPinning() |
fn |
Unpin all columns |
isPinned(id) |
fn → 'left' | 'right' | false |
Query pin status |
leftColumns |
string[] |
Currently left-pinned column IDs |
rightColumns |
string[] |
Currently right-pinned column IDs |
A floating debug panel showing current table state — sorting, pagination, filters, selection, expansion, grouping. Zero-config, dev-only.
import { TablecraftDevtools } from '@marvinackerman/tablecraft/devtools'
function MyTable() {
const { table } = useTable({ data, columns })
return (
<>
{/* your table */}
{process.env.NODE_ENV === 'development' && (
<TablecraftDevtools table={table} />
)}
</>
)
}Helpers for testing tables in Vitest / Jest without boilerplate.
import { renderTable } from '@marvinackerman/tablecraft/testing'
const { table, pagination, sorting } = renderTable({
data: users,
columns,
pagination: true,
sorting: true,
})Requires @testing-library/react:
npm i -D @testing-library/react
tablecraft is written in strict TypeScript with full generics. Your data type flows through the entire API:
type Product = { id: number; name: string; price: number }
const columns = createColumns<Product>([
{ accessorKey: 'name', header: 'Name' },
{ accessorKey: 'price', header: 'Price' },
])
// table is Table<Product>, row.original is Product
const { table } = useTable<Product>({ data: products, columns })Does this impose any styles? No. tablecraft is 100% headless. Bring your own CSS, Tailwind, shadcn/ui, or anything else.
Can I use the raw TanStack Table instance?
Yes. useTable returns the full Table<TData> instance as table. Use it for anything tablecraft doesn't cover.
Does it work with shadcn/ui?
Yes. shadcn's data table is built on TanStack Table. Replace the boilerplate with useTable and keep your shadcn components.
Does it work with Next.js App Router?
Yes. All hooks include "use client" directives. createColumns and inferColumns are pure functions that work anywhere.
What's the bundle size?
~21 KB ESM before gzip — ~6 KB for tablecraft itself plus ~15 KB for @tanstack/react-table, which you'd need anyway. Compare that to AG Grid (~300 KB) or Material React Table (~50 KB).
Does it support React 19? Yes. Tested against React 18 and 19.
| Package | Feature |
|---|---|
match-sorter |
Fuzzy search (fuzzy: true on useTable) |
@tanstack/react-query |
useQueryTable |
@testing-library/react |
tablecraft/testing utilities |
- v2 — Row expansion, grouping + aggregation, ARIA + keyboard navigation, inline editing, TanStack Query integration (
useQueryTable), URL state sync, state persistence, devtools, testing utilities - v2.1 — Infinite scroll (
useInfiniteTable) - v3 — Column pinning, multi-row editing, Zod column schemas, CLI scaffold
MIT