Headless
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. The hooks own state and data logic. Markup,
ARIA roles, and styles are yours to write.
Importing
import { useColumns, useTableState, useTableData } from 'react-data-table-component/headless';
The /headless entry contains only the hooks, the pinning helpers, and their types. It does
not include <DataTable /> and does not inject the stylesheet. The same hooks are
also exported from the main react-data-table-component entry, which is convenient when you
already render a <DataTable /> elsewhere, but that entry always loads the component
and its CSS.
When to use this
- You have a design system and can't override the default styles far enough
- You're building inside a heavily customized 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 → sortedData
• (skipped in server mode)"]
E["useColumnFilter (optional)
─────────────────
• per-column filter state
• filteredData(sortedData)"]
G["Page slice
─────────────────
• slice by currentPage + rowsPerPage
• always after filtering"]
end
F(["Rendered rows"])
A --> B
A --> C
B --> C
C --> D
D --> E
E --> G
G --> F
Component 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 to
grid/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. See
Keyboard 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 (pointer-driven: mouse, touch, and pen) |
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 |
None of them render JSX or load CSS. A few return class names or read data-* attributes
that you put on your own elements. Those cases are called out in the
hook reference, which has the signature, options, defaults, and
return value of each one.
Basic example
For a complete, styled version with row selection, see the Headless table with Tailwind example.
import {
useColumns,
useTableState,
useTableData,
useColumnFilter,
SortOrder,
type TableColumn,
} from 'react-data-table-component/headless';
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[] }) {
// 1. Column order + default sort
const { tableColumns, defaultSortColumn, defaultSortDirection } = useColumns(columns, {
defaultSortFieldId: 'name',
});
// 2. Sort / page / selection state. Only `data` is required.
const { tableState, handleSort, handleChangePage } = useTableState({
data,
defaultSortColumn,
defaultSortDirection,
});
const { selectedColumn, sortDirection, sortColumns, currentPage, rowsPerPage } = tableState;
// 3. Sorted rows
const { sortedData } = useTableData({
data,
selectedColumn,
sortDirection,
sortColumns,
currentPage,
rowsPerPage,
});
// 4. Optional: per-column filtering. Filter the full sorted set, then slice the page.
const { filteredData } = useColumnFilter(tableColumns);
const filteredRows = filteredData(sortedData);
const rows = filteredRows.slice((currentPage - 1) * rowsPerPage, currentPage * rowsPerPage);
const totalPages = Math.max(1, Math.ceil(filteredRows.length / rowsPerPage));
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)} disabled={currentPage <= 1}>
Previous page
</button>
<button onClick={() => handleChangePage(currentPage + 1)} disabled={currentPage >= totalPages}>
Next page
</button>
</div>
);
}
Filter before you paginate
Run filteredData on sortedData and slice the page yourself, as above. Filtering
tableRows with pagination: true filters a single page, so a match on page 3 never
shows while you are on page 1. When a filter changes, call handleChangePage(1), since the
narrower result set may not reach the current page.
If you don't filter, pass pagination: true to useTableData and render
tableRows, which is already sliced to the current page.
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 | initialColumnWidths + onColumnResize |
| 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
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.
Live example: all four hooks
A custom-markup table built entirely from useColumns, useTableState, useTableData, and useColumnFilter. No DataTable component. Sort by clicking headers, filter using the inputs (filters search every page, not only the visible one), and paginate below.
Custom markup. No DataTable component used. All logic from the four headless hooks.
| Name ↑ | Department ↕ | Salary ↕ |
|---|---|---|
| Alex Kim | Engineering | $162,000 |
| Aria Chen | Engineering | $155,000 |
| Casey Morgan | HR | $88,000 |
| Drew Park | Analytics | $99,000 |
| Jamie Okafor | Engineering | $141,000 |
import {
useColumns, useTableState, useTableData, useColumnFilter,
type TableColumn, SortOrder,
} from 'react-data-table-component/headless';
const rawColumns: TableColumn<Employee>[] = [
{ id: 'name', name: 'Name', selector: r => r.name, sortable: true },
{ id: 'dept', name: 'Department', selector: r => r.department, sortable: true },
{ id: 'salary', name: 'Salary', selector: r => r.salary, sortable: true },
];
function HeadlessTable({ data }: { data: Employee[] }) {
const { tableColumns, defaultSortColumn, defaultSortDirection } = useColumns(rawColumns, {
defaultSortFieldId: 'name',
});
const { tableState, handleSort, handleChangePage } = useTableState({
data, defaultSortColumn, defaultSortDirection, paginationPerPage: 5,
});
const { selectedColumn, sortDirection, sortColumns, currentPage, rowsPerPage } = tableState;
// Sort only. The page is sliced after filtering, below.
const { sortedData } = useTableData({
data, selectedColumn, sortDirection, sortColumns, currentPage, rowsPerPage,
});
// Filter the full sorted set, then slice the page.
const { filterValues, handleFilterChange, filteredData } = useColumnFilter(tableColumns);
const filteredRows = filteredData(sortedData);
const rows = filteredRows.slice((currentPage - 1) * rowsPerPage, currentPage * rowsPerPage);
const totalPages = Math.max(1, Math.ceil(filteredRows.length / rowsPerPage));
return (
<div>
{/* Filter inputs: your own markup */}
<div style={{ display: 'flex', gap: 8, marginBottom: 10 }}>
{tableColumns.map(col => (
<input key={col.id}
placeholder={`Filter ${col.name}…`}
value={filterValues[col.id!]?.condition1?.value ?? ''}
onChange={e => {
handleFilterChange(col.id!, { condition1: { operator: 'contains', value: e.target.value } });
handleChangePage(1); // the narrower result set may not reach this page
}}
/>
))}
</div>
{/* Table: completely custom markup */}
<table>
<thead>
<tr>
{tableColumns.map(col => (
<th key={col.id}
onClick={() => col.sortable && handleSort({
type: 'SORT_CHANGE', selectedColumn: col,
additive: false, defaultSortDirection: SortOrder.ASC,
direction: selectedColumn?.id === col.id && sortDirection === SortOrder.ASC
? SortOrder.DESC : SortOrder.ASC,
clearSelectedOnSort: false,
})}
>
{col.name}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map(row => (
<tr key={row.id}>
{tableColumns.map(col => (
<td key={col.id}>{String(col.selector?.(row) ?? '')}</td>
))}
</tr>
))}
</tbody>
</table>
{/* Pagination: your own controls */}
<button onClick={() => handleChangePage(currentPage - 1)} disabled={currentPage <= 1}>‹</button>
<span>Page {currentPage} of {totalPages}</span>
<button onClick={() => handleChangePage(currentPage + 1)} disabled={currentPage >= totalPages}>›</button>
</div>
);
}