@marvinackerman/tablecraft
v2.5.0
Published
Batteries-included TanStack Table wrapper for React — TypeScript-first, headless, zero UI lock-in
Downloads
58
Maintainers
Readme
tablecraft
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-tableQuick Start
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.
Why tablecraft?
@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 |
API Reference
useTable — Primary Hook
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 |
createColumns — Column Definition Helper
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>,
},
])inferColumns — Auto Column Inference
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' },
})useServerTable — Server-side Tables
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 })
},
})useQueryTable — TanStack Query Integration
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-queryuseInfiniteTable — Infinite Scroll / Load More
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-queryuseInfiniteScroll — Automatic Scroll Wiring
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 |
useTableA11y — ARIA + Keyboard Navigation
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) |
useEditableRows — Inline Editing
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>
))}useMultiRowEditing
Edit, validate, and save multiple rows simultaneously. Per-row save and bulk save both supported.
import { useTable, useMultiRowEditing } from '@marvinackerman/tablecraft'
const { table } = useTable({ data, columns })
const {
editingRowIds,
startEditing,
setField,
getDraft,
getErrors,
saveRow,
cancelRow,
saveAll,
cancelAll,
isEditing,
isDirty,
hasUnsavedChanges,
isSavingAll,
} = useMultiRowEditing(table, {
// Called by saveRow() and saveAll() (when onSaveAll is not provided)
onSave: async (rowId, draft) => {
const errors = await api.updateUser(draft)
if (errors) return errors // { name: 'Too long' } keeps row in edit mode
// return void / undefined → row exits edit mode
},
// Optional: called by saveAll() for a real batch API call
onSaveAll: async (rows) => {
const result = await api.bulkUpdate(rows)
// Return Record<rowId, errors> — rows with errors stay in edit mode
return result.errors
},
})
// In your row render:
{rows.map(row => (
<tr key={row.id}>
{isEditing(row.id) ? (
<>
<td>
<input
value={getDraft(row.id).name ?? ''}
onChange={e => setField(row.id, 'name', e.target.value)}
/>
{getErrors(row.id).name && <span>{getErrors(row.id).name}</span>}
</td>
<td>
<button onClick={() => saveRow(row.id)}>Save</button>
<button onClick={() => cancelRow(row.id)}>Cancel</button>
</td>
</>
) : (
<>
<td>{row.original.name}</td>
<td><button onClick={() => startEditing(row.id)}>Edit</button></td>
</>
)}
</tr>
))}
{hasUnsavedChanges && (
<button onClick={saveAll} disabled={isSavingAll}>
{isSavingAll ? 'Saving…' : 'Save All'}
</button>
)}Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| onSave | (rowId, draft) => void \| errors \| Promise<...> | — | Per-row save callback. Return a non-empty errors object to stay in edit mode. |
| onSaveAll | (rows) => void \| Record<rowId, errors> \| Promise<...> | — | Batch save callback. If omitted, saveAll() calls onSave for each dirty row in parallel. |
Return
| Property | Type | Description |
|----------|------|-------------|
| editingRowIds | string[] | Rows currently in edit mode |
| savingRows | string[] | Rows mid per-row save |
| isSavingAll | boolean | true during saveAll() |
| hasUnsavedChanges | boolean | true if any row has unsaved changes |
| startEditing(id) | fn | Enter edit mode for a row (no-op if already editing) |
| setField(id, field, value) | fn | Update a draft field |
| saveRow(id) | async fn | Save one row via onSave |
| cancelRow(id) | fn | Discard draft and exit edit mode for one row |
| saveAll() | async fn | Save all dirty rows |
| cancelAll() | fn | Discard all drafts and exit edit mode |
| isEditing(id) | fn → boolean | Is this row in edit mode? |
| isDirty(id) | fn → boolean | Does this row have unsaved changes? |
| dirtyFields(id) | fn → (keyof TData)[] | Which fields have changed? |
| getDraft(id) | fn → Partial<TData> | Current draft for a row |
| getErrors(id) | fn → Partial<Record<keyof TData, string>> | Current field errors for a row |
| isSavingRow(id) | fn → boolean | Is this row currently being saved? |
useVirtualRows
Render only the rows visible in the viewport — for large datasets where rendering all rows at once causes lag. Requires @tanstack/react-virtual:
npm install @tanstack/react-virtualimport { useTable, useVirtualRows } from '@marvinackerman/tablecraft'
import { flexRender } from '@tanstack/react-table'
const { table } = useTable({ data, columns })
const { virtualRows, totalHeight, containerRef, scrollToIndex } = useVirtualRows(table, {
rowHeight: 48,
})
// Virtualized tables use div-based rendering — position: absolute requires it.
// Use role="row" and role="cell" for screen reader compatibility.
<div ref={containerRef} style={{ height: 600, overflow: 'auto' }}>
<div style={{ height: totalHeight, position: 'relative' }}>
{virtualRows.map(({ row, start, size }) => (
<div
key={row.id}
role="row"
style={{ position: 'absolute', top: start, height: size, width: '100%' }}
>
{row.getVisibleCells().map(cell => (
<div key={cell.id} role="cell">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</div>
))}
</div>
))}
</div>
</div>
// Works with useTable, useQueryTable, and useInfiniteTable equally.
// To scroll programmatically:
scrollToIndex(42)Note: Standard
<table>/<tbody>/<tr>markup is incompatible with virtualization because browsers do not supportposition: absoluteinside<tbody>. Use<div role="table">,<div role="row">, and<div role="cell">— semantically equivalent for screen readers.
Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| rowHeight | number | — (required) | Fixed height of every row in pixels |
| overscan | number | 5 | Extra rows rendered above and below the visible area to prevent scroll flicker on fast scrolling |
Return
| Property | Type | Description |
|----------|------|-------------|
| virtualRows | VirtualRow<TData>[] | Only the rows currently visible in the viewport |
| totalHeight | number | Total scroll height in px — set as the height of the inner wrapper div |
| containerRef | RefObject<HTMLDivElement> | Attach to the outer scroll container div |
| scrollToIndex | (index: number) => void | Programmatically scroll to any row by its position in the full rows array |
Each VirtualRow<TData>:
| Field | Type | Description |
|-------|------|-------------|
| row | Row<TData> | Full TanStack Row — row.original, row.id, row.getVisibleCells(), etc. |
| index | number | Position in the full rows array |
| start | number | Pixel offset from top — use as top in absolute positioning |
| size | number | Row height in px (always equals rowHeight in fixed mode) |
TableKitProvider — Global Defaults
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>
)
}State Persistence
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')URL State Sync
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)
},
})Granular Hooks
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.
Column Pinning
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 |
Devtools
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} />
)}
</>
)
}Testing Utilities
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/reactTypeScript
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 })FAQ
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.
Optional Peer Dependencies
| Package | Feature |
|---------|---------|
| match-sorter | Fuzzy search (fuzzy: true on useTable) |
| @tanstack/react-query | useQueryTable |
| @testing-library/react | tablecraft/testing utilities |
| @tanstack/react-virtual | useVirtualRows |
Roadmap
- 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
License
MIT
