Headless hooks
react-data-table-component ships the hooks (and, for pinning, plain functions) that power<DataTable /> internally. You can import them directly and compose them yourself when you need full control over markup and styles, without giving up sorting, pagination, selection, filtering, resizing, pinning, keyboard navigation, and export logic.
This is the middle ground between using the styled <DataTable /> as-is and rebuilding everything from scratch with a fully headless library.
When to use this
- You have a design system and can't override the default styles far enough
- You're building inside a heavily customised UI (e.g. a design system component library)
- You want the table logic but need to render it inside something other than a standard
<table>or flex layout - You want to compose only some hooks (e.g. sorting + pagination) and handle the rest yourself
If the default <DataTable /> with customStyles covers your needs, use that. It is less work.
The data pipeline
Each hook owns one layer of the pipeline. Data flows top-to-bottom; state flows back up via callbacks. Understanding this makes it straightforward to drop any layer and replace it with your own logic.
%%{init: {"theme": "base", "themeVariables": {"primaryColor": "#f0f4ff", "primaryTextColor": "#1e293b", "primaryBorderColor": "#6366f1", "lineColor": "#6366f1", "secondaryColor": "#f8fafc", "tertiaryColor": "#f1f5f9"}, "layout": {"algorithm": "elk"}}}%%
flowchart TD
A(["Your data[]"])
subgraph pipeline ["Hook pipeline"]
direction TB
B["useColumns
─────────────────
• decorates column ids
• resolves defaultSortFieldId
• manages drag-to-reorder"]
C["useTableState
─────────────────
• currentPage
• rowsPerPage
• sortDirection
• selectedRows"]
D["useTableData
─────────────────
• sorts client-side
• paginates + slices
• (skipped in server mode)"]
E["useColumnFilter (optional)
─────────────────
• per-column filter state
• filteredData() helper"]
end
F(["Rendered rows"])
A --> B
A --> C
B --> C
C --> D
D --> E
E --> FComponent tree
When you use <DataTable />, it renders the following structure. Each node has a stable CSS class so you can target it from your stylesheet. When you use the hooks directly, you own this tree entirely.
%%{init: {"theme": "base", "themeVariables": {"primaryColor": "#f0f4ff", "primaryTextColor": "#1e293b", "primaryBorderColor": "#6366f1", "lineColor": "#6366f1", "secondaryColor": "#f8fafc"}, "layout": "elk"}}%%
graph TD
W["div.rdt_wrapper (root)"]
H["Header (optional title + actions)"]
S["Subheader (optional: filters, search)"]
SC["div.rdt_responsiveWrapper (scroll container)"]
T["div.rdt_table aria role=table
(role=grid with cellNavigation)"]
TH["div.rdt_TableHead"]
THR["div.rdt_TableHeadRow"]
TC["div.rdt_TableCol ×N columns
data-column-id=..."]
RH["span.rdt_columnSortable (sort handle + label)
+ div.rdt_resizeHandle (when resizable)"]
TB["div.rdt_TableBody"]
TR["div.rdt_row ×N rows
id=row-..."]
TD["div.rdt_TableCell ×N columns
aria role=cell (role=gridcell with cellNavigation)
data-column-id=..."]
CEL["div[data-tag=allowRowEvents]
cell content"]
PS["PinnedScrollbar (when row pinning enabled)"]
PG["Pagination (when pagination enabled)"]
W --> H
W --> S
W --> SC
SC --> T
T --> TH
T --> TB
TH --> THR
THR --> TC
TC --> RH
TB --> TR
TR --> TD
TD --> CEL
W --> PS
W --> PG<DataTable />'s ARIA roles switch from table/cell togrid/gridcell when cellNavigation is enabled, adding keyboard grid navigation on top of the same tree. The useCellNavigation hook below is the same logic that powers it — compose it into your own markup to get the same roving-tabindex behavior. SeeKeyboard navigation for the interaction pattern it implements.
The core pipeline hooks
| Hook | What it does |
|---|---|
useColumns | Manages column order and drag-to-reorder |
useTableState | Sort, pagination, and row-selection state + dispatchers |
useTableData | Sorts and paginates the raw data into renderable rows |
useColumnFilter | Per-column filter values and client-side filter logic |
Feature hooks
Compose any of these on top of the pipeline as needed — none of them depend on each other.
| Hook | What it does |
|---|---|
useColumnVisibility | Show/hide columns by id, independent of the sort/filter pipeline |
useColumnResize | Drag-to-resize column widths (mouse-driven, DOM-only state) |
useCellNavigation | Keyboard grid navigation — arrows, Home/End, roving tabindex. Same logic behind cellNavigation |
useTableExport | Generates CSV / JSON from columns and rows, with download + clipboard helpers |
getPinnedOffsets / getPinnedTotalWidths / getPinnedCellMeta | Pure functions (not hooks) that compute sticky-column offsets and shadow metadata for column.pinned |
All are pure: no JSX, no CSS, no hard dependency on the rendered component tree.
Basic example
import { useState } from 'react';
import {
useColumns,
useTableState,
useTableData,
useColumnFilter,
SortOrder,
type TableColumn,
} from 'react-data-table-component';
interface Person {
id: number;
name: string;
email: string;
role: string;
}
const columns: TableColumn<Person>[] = [
{ id: 'name', name: 'Name', selector: row => row.name, sortable: true },
{ id: 'email', name: 'Email', selector: row => row.email },
{ id: 'role', name: 'Role', selector: row => row.role },
];
function MyTable({ data }: { data: Person[] }) {
const [rowsPerPage] = useState(10);
// 1. Column order + drag state
const { tableColumns, defaultSortColumn, defaultSortDirection } = useColumns(
columns,
() => {}, // onColumnOrderChange
undefined, // onColumnGroupOrderChange
undefined, // columnGroups
null, // defaultSortFieldId
true, // defaultSortAsc
);
// 2. Sort / page / selection state
const { tableState, handleSort, handleChangePage } = useTableState({
data,
keyField: 'id',
defaultSortColumn,
defaultSortDirection,
paginationDefaultPage: 1,
paginationPerPage: rowsPerPage,
paginationServer: false,
paginationServerOptions: {},
paginationTotalRows: 0,
pagination: true,
selectableRowsSingle: false,
selectableRowsVisibleOnly: false,
selectableRowSelected: null,
clearSelectedRows: false,
paginationResetDefaultPage: false,
onSelectedRowsChange: () => {},
onSort: () => {},
onChangePage: () => {},
onChangeRowsPerPage: () => {},
});
const { selectedColumn, sortDirection, sortColumns, currentPage } = tableState;
// 3. Sorted + paginated rows
const { tableRows } = useTableData({
data,
columns: tableColumns,
selectedColumn,
sortDirection,
sortColumns,
currentPage,
rowsPerPage,
pagination: true,
paginationServer: false,
sortServer: false,
sortFunction: null,
onSort: () => {},
});
// 4. Optional: per-column filtering (uncontrolled — internal state)
const { filteredData } = useColumnFilter(tableColumns);
const rows = filteredData(tableRows);
return (
<div>
<table>
<thead>
<tr>
{tableColumns.map(col => (
<th
key={col.id}
onClick={() =>
col.sortable &&
handleSort({
type: 'SORT_CHANGE',
selectedColumn: col,
clearSelectedOnSort: false,
additive: false,
defaultSortDirection: SortOrder.ASC,
})
}
>
{col.name}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr key={row.id}>
{tableColumns.map(col => (
<td key={col.id}>{col.selector?.(row, i)}</td>
))}
</tr>
))}
</tbody>
</table>
<button onClick={() => handleChangePage(currentPage + 1)}>Next page</button>
</div>
);
}Hook signatures
useColumns<T>
function useColumns<T>(
columns: TableColumn<T>[],
onColumnOrderChange: (nextOrder: TableColumn<T>[]) => void,
onColumnGroupOrderChange: ((nextGroups: ColumnGroup[], nextColumns: TableColumn<T>[]) => void) | undefined,
columnGroups: ColumnGroup[] | undefined,
defaultSortFieldId: string | number | null | undefined,
defaultSortAsc: boolean,
): ColumnsHook<T>;Pass undefined for onColumnGroupOrderChange and columnGroupsif you're not using column groups.
| Property | Type | Description |
|---|---|---|
tableColumns | TableColumn<T>[] | Decorated columns in current drag order |
draggingColumnId | string | ID of the column being dragged, or '' |
defaultSortColumn | TableColumn<T> | The column matching defaultSortFieldId |
defaultSortDirection | SortOrder | 'asc' or 'desc' from defaultSortAsc |
handleDragStart | DragEventHandler | Attach to onDragStart on the header cell |
handleDragEnter | DragEventHandler | Attach to onDragEnter on the header cell |
handleDragOver | DragEventHandler | Attach to onDragOver on the header cell |
handleDragLeave | DragEventHandler | Attach to onDragLeave on the header cell |
handleDragEnd | DragEventHandler | Attach to onDragEnd on the header cell |
All five drag handlers must be attached for reordering to work correctly.
useTableState<T>
function useTableState<T>(props: UseTableStateProps<T>): UseTableStateReturn<T>;Props:
| Prop | Type | Description |
|---|---|---|
data | T[] | Full dataset (used for selection total counts) |
keyField | string | Unique row identifier field |
defaultSortColumn | TableColumn<T> | Column active on first render |
defaultSortDirection | SortOrder | 'asc' | 'desc' |
pagination | boolean | Enable pagination state |
paginationDefaultPage | number | Starting page (1-based) |
paginationPerPage | number | Rows per page |
paginationServer | boolean | Skip internal page math |
paginationServerOptions | PaginationServerOptions | persistSelectedOnPageChange, persistSelectedOnSort |
paginationTotalRows | number | Total server rows (server mode only) |
paginationResetDefaultPage | boolean | Toggle to reset to page 1 |
selectableRowsSingle | boolean | Allow only one selected row |
selectableRowsVisibleOnly | boolean | "Select all" acts on current page only |
selectableRowSelected | (row: T) => boolean | null | Pre-select matching rows |
clearSelectedRows | boolean | Toggle to clear selection |
onSelectedRowsChange | (state) => void | Fires on any selection change |
onSort | (col, dir, rows, sortColumns) => void | Fires after sort state changes |
onChangePage | (page, total) => void | Fires after page changes |
onChangeRowsPerPage | (perPage, page) => void | Fires after rows-per-page changes |
Returns:
| Property | Type | Description |
|---|---|---|
tableState | TableState<T> | Full state snapshot (see below) |
handleSort | (action: SortAction<T>) => void | Dispatch a sort change |
handleSelectAllRows | (action: AllRowsAction<T>) => void | Dispatch select/deselect all |
handleSelectedRow | (action: SingleRowAction<T>) => void | Dispatch a single row toggle |
handleSelectedRange | (action: RangeRowAction<T>) => void | Dispatch a Shift-click range selection |
handleChangePage | (page: number) => void | Dispatch a page change |
handleChangeRowsPerPage | (perPage, rowCount) => void | Dispatch a per-page change |
handleClearSelectedRows | () => void | Programmatically clear all selections |
handleClearSort | () => void | Programmatically reset sort to the default |
SortAction<T> is { type: 'SORT_CHANGE', selectedColumn: TableColumn<T>, clearSelectedOnSort: boolean, additive: boolean, defaultSortDirection: SortOrder }.additive: true adds the column to the existing sort instead of replacing it (multi-column sort); most callers pass false.
TableState<T> shape:
| Field | Type |
|---|---|
currentPage | number |
rowsPerPage | number |
selectedColumn | TableColumn<T> |
sortDirection | SortOrder |
sortColumns | SortColumn<T>[] — full sort config in priority order; required by useTableData |
selectedRows | T[] |
selectedCount | number |
allSelected | boolean |
useTableData<T>
function useTableData<T>(props: UseTableDataProps<T>): UseTableDataReturn<T>;| Prop | Type | Description |
|---|---|---|
data | T[] | Source rows (full array for client-side; current-page array for server-side) |
columns | TableColumn<T>[] | From useColumns |
selectedColumn | TableColumn<T> | From tableState.selectedColumn |
sortDirection | SortOrder | From tableState.sortDirection |
currentPage | number | From tableState.currentPage |
rowsPerPage | number | From tableState.rowsPerPage |
pagination | boolean | Enable page slicing |
paginationServer | boolean | Skip page slicing (server already paginated) |
sortServer | boolean | Skip sorting (server already sorted) |
sortFunction | SortFunction<T> | null | Global custom sort comparator |
onSort | (col, dir, rows, sortColumns) => void | Called after sort; receives sorted rows |
sortColumns (from tableState.sortColumns) is required — omitting it is a type error.
| Property | Type | Description |
|---|---|---|
sortedData | T[] | All rows after sorting (full dataset, pre-slice) |
tableRows | T[] | Rows for the current page after sort + slice |
Pass tableRows (not sortedData) to useColumnFilter.filteredData() and then to your render.
useColumnFilter<T>
import type { FilterState } from 'react-data-table-component';
function useColumnFilter<T>(
columns: TableColumn<T>[],
controlledFilterValues?: Record<string | number, FilterState>,
onFilterChange?: (columnId: string | number, filter: FilterState) => void,
): UseColumnFilterResult<T>;Omit both optional arguments to run in uncontrolled mode (internal state). Pass both to run in controlled mode (you own the state).
| Property | Type | Description |
|---|---|---|
filterValues | Record<string | number, FilterState> | Current filter state per column ID |
handleFilterChange | (columnId, filter) => void | Call this when the user applies a filter |
filteredData | (data: T[]) => T[] | Apply all active filters to a row array |
filteredData is a stable memoised function — safe to call in render. Each column can define filterFunction: (row: T, filter: FilterState) => boolean to override the built-in operator logic. See Filtering for the fullFilterState type and operator reference.
useCellNavigation<T>
Powers the WAI-ARIA grid pattern behind cellNavigation: arrow keys, Home/End,Ctrl+Home/Ctrl+End, and a roving tabindex. It reads and writes plaindata-nav-row / data-nav-col attributes on your own cells — it doesn't know or care what markup they're attached to.
function useCellNavigation<T>(options: UseCellNavigationOptions<T>): {
activeCell: ActiveCell | null;
handleNavFocus: (e: React.FocusEvent<HTMLDivElement>) => void;
handleNavKeyDown: (e: React.KeyboardEvent<HTMLDivElement>) => void;
};| Option | Type | Description |
|---|---|---|
cellNavigation | boolean | Master on/off switch. When false, activeCell is always null. |
selectableRows | boolean | Reserves nav column 0 for a selection checkbox, if you render one. |
expandableRows | boolean | Reserves the next nav column for an expander button, if you render one. |
expandableRowsHideExpander | boolean | Skip the expander column reservation even when expandableRows is true. |
effectiveColumns | TableColumn<T>[] | Your current column list. Columns with omit: true are excluded from the nav grid. |
filteredTableRowCount | number | Row count for the currently visible page — clamps ArrowDown/End. |
showTableHead | boolean | Whether a header row participates in the grid (row -1). false removes header cells from the tab sequence entirely. |
Wire it up by spreading data-nav-row/data-nav-col (row -1 for the header) onto each focusable cell, setting tabIndex from activeCell, and attaching onFocus/onKeyDown to the table root:
function Row({ row, rowIndex, columns, activeCell }) {
return (
<tr>
{columns.map((col, colIndex) => (
<td
key={col.id}
data-nav-row={rowIndex}
data-nav-col={colIndex}
tabIndex={activeCell?.row === rowIndex && activeCell?.col === colIndex ? 0 : -1}
>
{col.selector?.(row, rowIndex)}
</td>
))}
</tr>
);
}
function MyTable({ data, columns }) {
const { activeCell, handleNavFocus, handleNavKeyDown } = useCellNavigation({
cellNavigation: true,
selectableRows: false,
expandableRows: false,
expandableRowsHideExpander: false,
effectiveColumns: columns,
filteredTableRowCount: data.length,
showTableHead: false,
});
return (
<table onFocus={handleNavFocus} onKeyDown={handleNavKeyDown}>
<tbody>
{data.map((row, i) => (
<Row key={row.id} row={row} rowIndex={i} columns={columns} activeCell={activeCell} />
))}
</tbody>
</table>
);
}Cells that hold an interactive widget instead of plain text (a checkbox, an expander button) should also set data-nav-widget="true" — the hook then focuses the widget itself rather than the cell wrapper, so Space/Enter activate it natively. SeeKeyboard navigation for the full key reference this reproduces.
useColumnResize
function useColumnResize(options?: UseColumnResizeOptions): {
columnWidths: Record<string | number, number>;
handleResizeStart: (columnId: string | number, e: React.MouseEvent) => void;
};| Option | Type | Description |
|---|---|---|
initialColumnWidths | Record<string | number, number> | Seed widths, e.g. hydrated from localStorage. |
onColumnResize | (columnId, width, allWidths) => void | Fires on mouseup with the settled width. |
Attach handleResizeStart to a drag handle's onMouseDown. The hook tracksmousemove/mouseup on document itself — you don't need to wire those up. Apply columnWidths[column.id] as an inline width on your header and body cells for that column.
Pinning: getPinnedOffsets, getPinnedTotalWidths, getPinnedCellMeta
Column pinning has no hook — there's no state to own beyond column.pinned on your own column list. These are pure functions: given your columns and current widths, they compute sticky offsets and shadow metadata.
function getPinnedOffsets<T>(
columns: TableColumn<T>[],
columnWidths: Record<string | number, number>,
selectableRows: boolean,
expandableRows: boolean,
expandableRowsHideExpander: boolean,
): PinnedOffsets;
function getPinnedTotalWidths<T>(
columns: TableColumn<T>[],
columnWidths: Record<string | number, number>,
selectableRows: boolean,
expandableRows: boolean,
expandableRowsHideExpander: boolean,
): { left: number; right: number };
function getPinnedCellMeta<T>(
column: TableColumn<T>,
pinnedOffsets: PinnedOffsets | undefined,
zIndex?: number,
): PinnedCellMeta;const pinnedOffsets = getPinnedOffsets(columns, columnWidths, false, false, false);
function Cell({ column, pinnedOffsets }: { column: TableColumn<Row>; pinnedOffsets: PinnedOffsets }) {
const meta = getPinnedCellMeta(column, pinnedOffsets, 1);
return (
<td className={meta.className} style={meta.style}>
{/* ... */}
</td>
);
}getPinnedCellMeta returns a style object (position: 'sticky', the computed left/right offset, and zIndex) that's enough to render pinned columns on its own. className adds rdt_pinLeftLast /rdt_pinRightFirst on the pinned boundary's edge column — those classes only draw a shadow if you import react-data-table-component/css (or match the selectors yourself); they're no-ops otherwise. See Column pinning for thecolumn.pinned API these functions read.
How useTableState and useTableData relate
useTableState owns the sort/page/selection state.useTableData takes that state as inputs and returns the derived rows to render. They are intentionally separate so you can skip useTableData entirely if you're handling sorting and pagination server-side.
// Server-side: you manage the rows, just use useTableState for UI state
const { tableState, handleSort, handleChangePage } = useTableState({ ..., paginationServer: true });
// When tableState changes, fire your own API call. No useTableData needed.
useEffect(() => {
fetchPage(tableState.currentPage, tableState.rowsPerPage, tableState.sortDirection);
}, [tableState.currentPage, tableState.rowsPerPage, tableState.sortDirection]);State ownership
| State | Owned by | Controlled prop |
|---|---|---|
| Current page | useTableState | paginationDefaultPage + onChangePage |
| Rows per page | useTableState | paginationPerPage + onChangeRowsPerPage |
| Selected rows | useTableState | selectableRowSelected + onSelectedRowsChange |
| Sort column + direction | useTableState | defaultSortFieldId + onSort |
| Column filters | useColumnFilter | filterValues + onFilterChange |
| Column widths | useColumnResize | None — DOM-only (see recipes) |
| Column order | useColumns | onColumnOrderChange |
| Expanded rows | local to each row component | expandableRowExpanded + onRowExpandToggled |
What you're responsible for
When you use the hooks directly, you own:
- Markup:
<table>,<div>, whatever fits your design system - Styles: no CSS is injected; you apply your own classes
- Accessibility:
role,aria-sort,aria-selected, screen-reader labels - Pagination UI: the hooks give you
currentPage,rowsPerPage, andhandleChangePage; you render the controls - Selection UI:
tableState.selectedRows,handleSelectedRow,handleSelectAllRowsgive you the data; you render checkboxes
useTableExport
Generates CSV or JSON from any set of columns and rows. Pair it withuseTableData and useColumnFilter to export exactly what the user sees — sorted, filtered, and paginated — rather than the raw dataset.
function useTableExport<T>(options: UseTableExportOptions<T>): UseTableExportResult;Options
| Option | Type | Default | Description |
|---|---|---|---|
columns | TableColumn<T>[] | required | Columns to include. Columns with omit: true are skipped automatically. |
rows | T[] | required | Rows to export. Pass the filtered/sorted slice for "current view" semantics. |
valueSource | 'selector' | 'format' | 'selector' | 'selector' exports raw values; 'format' runs column.format first. |
headerOverrides | Record<string | number, string> | — | Override the header label for columns whose name is a React node. |
columnOrder | (string | number)[] | — | Restrict and reorder exported columns by id. Omit to include all visible columns. |
Return value
| Property | Type | Description |
|---|---|---|
toCSV | () => string | Returns a CSV string. |
toJSON | () => string | Returns a pretty-printed JSON string. |
download | (filename: string, format?: 'csv' | 'json') => void | Triggers a browser file download. No-op on the server. |
copy | (format?: 'csv' | 'json') => Promise<void> | Copies to clipboard via the Clipboard API. |
Example — export the current filtered view
import {
useColumns,
useTableState,
useTableData,
useColumnFilter,
useTableExport,
type TableColumn,
} from 'react-data-table-component';
const columns: TableColumn<Employee>[] = [
{ id: 'name', name: 'Name', selector: r => r.name },
{ id: 'dept', name: 'Department', selector: r => r.department },
{ id: 'salary', name: 'Salary', selector: r => r.salary },
];
function MyTable({ data }: { data: Employee[] }) {
const { tableColumns } = useColumns(columns, () => {}, undefined, undefined, null, true);
const { tableState } = useTableState({ data, keyField: 'id' /* … */ });
const { tableRows } = useTableData({ data, columns: tableColumns /* … */ });
const { filterValues, handleFilterChange, filteredData } = useColumnFilter(tableColumns);
const rows = filteredData(tableRows);
const { download, copy } = useTableExport({ columns: tableColumns, rows });
return (
<div>
<button onClick={() => download('employees.csv')}>Download CSV</button>
<button onClick={() => download('employees.json', 'json')}>Download JSON</button>
<button onClick={() => copy()}>Copy to clipboard</button>
{/* … your table markup … */}
</div>
);
}See the Export (CSV / JSON) page for a full worked example including integration with the <DataTable /> component's onExport callback.
Staying on the styled <DataTable />
None of these exports affect the default <DataTable />. It continues to work identically. The hooks are an additive unlocked door, not a replacement.
Live demo
The example below is built entirely from the four hooks. No <DataTable /> component. It has sortable headers, per-column filter inputs, manual pagination controls, and striped rows, all in custom markup with no injected CSS.