npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@marvinackerman/tablecraft

v2.5.0

Published

Batteries-included TanStack Table wrapper for React — TypeScript-first, headless, zero UI lock-in

Downloads

58

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-table

Quick 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-query

useInfiniteTable — 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-query

useInfiniteScroll — 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 | null

Keyboard 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-virtual
import { 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 support position: absolute inside <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: sticky is 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/react

TypeScript

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