Core Concepts
Table Instance
Every TableCraft component is backed by a single useReactTable instance from TanStack Table v8. This instance is the single source of truth for all table state: pagination (current page index and page size), sorting (active sort column and direction), column filters (per-column filter values used by faceted dropdowns and search), column visibility (which columns are shown or hidden), and row selection (which rows have their checkbox checked). All toolbar controls, header cells, and body cells read from and write back to this same instance, ensuring that state is never duplicated or desynchronized across the component tree.
Column Definitions
Columns are declared as a typed ColumnDef<TData>[] array. TanStack Table distinguishes two fundamental column types: data columns, which are linked to a row field via accessorKey or accessorFn and therefore support sorting and filtering, and display columns, which carry no data accessor and are used purely for UI chrome such as selection checkboxes or action menus.
import { ColumnDef } from "@tanstack/react-table"
import { DataTableColumnHeader } from "@/components/table/data-table-column-header"
interface Order {
id: string
customer: string
total: number
status: "pending" | "shipped" | "delivered"
}
const columns: ColumnDef<Order>[] = [
// Data column — backed by a row field via accessorKey or accessorFn
{
accessorKey: "customer",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Customer" />
),
// Optional cell renderer; defaults to the raw value as a string
cell: ({ row }) => (
<span className="font-medium">{row.getValue("customer")}</span>
),
},
// Data column with a custom accessor function
{
id: "total",
accessorFn: (row) => row.total,
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Total" />
),
cell: ({ getValue }) =>
new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(getValue<number>()),
},
// Display column — no data accessor; used for UI chrome like checkboxes or actions
{
id: "actions",
// No accessorKey or accessorFn — cannot be sorted or filtered
cell: ({ row }) => <RowActionsDropdown row={row} />,
},
]"select" is automatically prepended by the row-selection plugin and renders a checkbox in the header and each body cell. "actions" is conventionally placed last and renders a per-row dropdown menu via TableActionsRow. Both IDs are recognized by the column-visibility toggle and are excluded from it by default. Using DataTableColumnHeader for all sortable data columns is strongly recommended as it provides a consistent sort indicator, accessible button semantics, and the correct ARIA attributes.Data Flow
Data moves through the table in a unidirectional pipeline. Props (data, columns, config, feature arrays) are passed to the component and forwarded to useReactTable along with the resolved merged config. useReactTable produces a stable table instance whose row models are recomputed whenever state changes. The URL sync layer reads the current searchParams on mount to hydrate initial state and writes updated params back to the URL on every state change, making the table's current view fully shareable and browser-history-aware. Rendering is driven entirely by the table instance — the toolbar, header row, body rows, and footer pagination bar all call into the instance rather than maintaining their own local copies of state.
URL Synchronization
TableCraft maps its internal state to URL search parameters using a fixed naming convention. This enables deep-linking, browser back/forward navigation, and server-side rendering of the correct initial page without any manual wiring.
pagepagination.pageIndex + 12per_pagepagination.pageSize20sortsorting[0].id + directionname.ascsearchglobalFilteralice{columnId}columnFilters (faceted)status=active,pendingisCursorPagination is active, the page and per_page parameters are not written to the URL because page numbers are meaningless in cursor-based pagination. All other URL parameters (sort, filters) continue to sync normally.State Management Model
By default, TableCraft operates in uncontrolled mode: all state (sorting, pagination, filters, visibility, selection) is owned inside the component via React useState hooks and synchronized to the URL. You never need to lift state unless you specifically want to drive the table from an external source.
// Internal (uncontrolled) state — the table owns everything
const [sorting, setSorting] = useState<SortingState>([])
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
})
const table = useReactTable({
data,
columns,
state: { sorting, columnFilters, columnVisibility, rowSelection, pagination },
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
manualPagination: false, // flip to true for server-side mode
})For server-side operations, individual state slices can be switched to callback mode by passing the corresponding onPaginationChange, onSortingChange, or onColumnFiltersChange props. When a callback is provided for a given slice, the table sets manual* to true for that slice and delegates the operation to your handler instead of applying it client-side. The URL sync layer continues to work transparently regardless of which slices are controlled.
Controlled vs. Uncontrolled
TableCraft supports five distinct operating modes, selected by which props you provide:
- Uncontrolled (default) — all data is passed as a static array, all operations run client-side, and the URL reflects current state. Suitable for data sets of up to a few thousand rows.
- Server Pagination — pass
manualPaginationand providepageCountfrom the server. The table firesonPaginationChangewhen the user navigates; you re-fetch and replacedata. - Server Search — additionally pass
manualFilteringand handleonColumnFiltersChangeoronGlobalFilterChangeto forward the search term to your API. - Server Filters — pass
manualFilteringfor faceted filters as well. The table constructs the filter object and hands it to your handler; you translate it into query parameters for your back end. - Cursor Pagination — pass
isCursorPaginationandcursorPaginationDatafor APIs that use opaque cursors (GraphQL Relay, Stripe, keyset). Navigation is driven byonNextPage/onPreviousPagecallbacks. BothmanualPaginationandmanualSortingare set totrueautomatically. URL sync forpage/per_pageis disabled since page numbers have no meaning in cursor-based pagination.
Config Merging Strategy
TableCraft resolves its final configuration through a four-layer deep merge. Each successive layer overrides only the keys it explicitly sets, leaving unspecified keys inherited from the layer below. The merge is performed with structuredClone + recursive object spread so arrays are replaced rather than concatenated (except for plugins, which are accumulated across layers).
// Layer 1 — library defaults (built-in, always present)
const libraryDefaults: TableConfig = {
features: { search: true, filters: true, pagination: true, export: false },
pagination: { defaultPageSize: 10, pageSizeOptions: [10, 20, 50, 100] },
}
// Layer 2 — global provider config (set once for your whole application)
<TableCraftProvider
config={{
pagination: { defaultPageSize: 20 },
features: { export: true },
}}
>
<App />
</TableCraftProvider>
// Layer 3 — per-instance prop config (overrides global for this table only)
<ClientSideTable
config={{
pagination: { defaultPageSize: 5, pageSizeOptions: [5, 10, 25] },
}}
...
/>
// Layer 4 — plugin config (injected by TablePlugin implementations)
// Merged result for the table above:
// {
// features: { search: true, filters: true, pagination: true, export: true },
// pagination: { defaultPageSize: 5, pageSizeOptions: [5, 10, 25] },
// }Feature Flags
The features key in the resolved config is a flat boolean map. Each flag gates a discrete capability and defaults to the value shown. Disabling a flag prevents both the render and any associated event listeners or URL params for that feature.
search— global search input in the toolbar (true)filters— faceted filter dropdowns (true)pagination— footer pagination bar and page-size selector (true)columnVisibility— column visibility toggle button (true)rowSelection— checkbox column and bulk-action toolbar (false)rowNumbers— auto-generated row number as first column (true)export— CSV / Excel export button (false)expandableRows— expand/collapse sub-row support (false)columnReorder— drag-to-reorder column headers (false)columnResize— drag-to-resize column widths (false)
Plugin Architecture
Plugins are objects that conform to the TablePlugin interface. They allow third-party code or application-level modules to extend the table's behavior without forking or wrapping the component. A plugin can inject columns, contribute config, observe state changes, and render additional toolbar controls. Plugins are applied in array order; config contributions from each plugin are deep-merged in sequence after the four-layer config but before prop-level overrides.
// The TablePlugin interface
export interface TablePlugin<TData = unknown> {
/** Unique identifier for the plugin */
id: string
/** Called once when the table instance is created */
onInit?: (table: Table<TData>) => void
/** Merge additional config into the resolved config before the table renders */
config?: Partial<TableConfig>
/** Inject additional columns (e.g. a row-selection checkbox column) */
columns?: ColumnDef<TData>[]
/** Called whenever table state changes */
onStateChange?: (state: TableState) => void
/** Render additional toolbar controls */
renderToolbarEnd?: () => React.ReactNode
}
// Example: an audit-log plugin that records every sort change
const auditPlugin: TablePlugin = {
id: "audit-log",
onStateChange: (state) => {
if (state.sorting.length > 0) {
analytics.track("table_sorted", { sorting: state.sorting })
}
},
}
// Usage
<ClientSideTable
data={data}
columns={columns}
plugins={[auditPlugin]}
/>