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

HookWhat it does
useColumnsManages column order and drag-to-reorder
useTableStateSort, pagination, and row-selection state + dispatchers
useTableDataSorts and paginates the raw data into renderable rows
useColumnFilterPer-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.

HookWhat it does
useColumnVisibilityShow/hide columns by id, independent of the sort/filter pipeline
useColumnResizeDrag-to-resize column widths (mouse-driven, DOM-only state)
useCellNavigationKeyboard grid navigation — arrows, Home/End, roving tabindex. Same logic behind cellNavigation
useTableExportGenerates CSV / JSON from columns and rows, with download + clipboard helpers
getPinnedOffsets / getPinnedTotalWidths / getPinnedCellMetaPure 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.

PropertyTypeDescription
tableColumnsTableColumn<T>[]Decorated columns in current drag order
draggingColumnIdstringID of the column being dragged, or ''
defaultSortColumnTableColumn<T>The column matching defaultSortFieldId
defaultSortDirectionSortOrder'asc' or 'desc' from defaultSortAsc
handleDragStartDragEventHandlerAttach to onDragStart on the header cell
handleDragEnterDragEventHandlerAttach to onDragEnter on the header cell
handleDragOverDragEventHandlerAttach to onDragOver on the header cell
handleDragLeaveDragEventHandlerAttach to onDragLeave on the header cell
handleDragEndDragEventHandlerAttach 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:

PropTypeDescription
dataT[]Full dataset (used for selection total counts)
keyFieldstringUnique row identifier field
defaultSortColumnTableColumn<T>Column active on first render
defaultSortDirectionSortOrder'asc' | 'desc'
paginationbooleanEnable pagination state
paginationDefaultPagenumberStarting page (1-based)
paginationPerPagenumberRows per page
paginationServerbooleanSkip internal page math
paginationServerOptionsPaginationServerOptionspersistSelectedOnPageChange, persistSelectedOnSort
paginationTotalRowsnumberTotal server rows (server mode only)
paginationResetDefaultPagebooleanToggle to reset to page 1
selectableRowsSinglebooleanAllow only one selected row
selectableRowsVisibleOnlyboolean"Select all" acts on current page only
selectableRowSelected(row: T) => boolean | nullPre-select matching rows
clearSelectedRowsbooleanToggle to clear selection
onSelectedRowsChange(state) => voidFires on any selection change
onSort(col, dir, rows, sortColumns) => voidFires after sort state changes
onChangePage(page, total) => voidFires after page changes
onChangeRowsPerPage(perPage, page) => voidFires after rows-per-page changes

Returns:

PropertyTypeDescription
tableStateTableState<T>Full state snapshot (see below)
handleSort(action: SortAction<T>) => voidDispatch a sort change
handleSelectAllRows(action: AllRowsAction<T>) => voidDispatch select/deselect all
handleSelectedRow(action: SingleRowAction<T>) => voidDispatch a single row toggle
handleSelectedRange(action: RangeRowAction<T>) => voidDispatch a Shift-click range selection
handleChangePage(page: number) => voidDispatch a page change
handleChangeRowsPerPage(perPage, rowCount) => voidDispatch a per-page change
handleClearSelectedRows() => voidProgrammatically clear all selections
handleClearSort() => voidProgrammatically 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:

FieldType
currentPagenumber
rowsPerPagenumber
selectedColumnTableColumn<T>
sortDirectionSortOrder
sortColumnsSortColumn<T>[] — full sort config in priority order; required by useTableData
selectedRowsT[]
selectedCountnumber
allSelectedboolean

useTableData<T>

function useTableData<T>(props: UseTableDataProps<T>): UseTableDataReturn<T>;
PropTypeDescription
dataT[]Source rows (full array for client-side; current-page array for server-side)
columnsTableColumn<T>[]From useColumns
selectedColumnTableColumn<T>From tableState.selectedColumn
sortDirectionSortOrderFrom tableState.sortDirection
currentPagenumberFrom tableState.currentPage
rowsPerPagenumberFrom tableState.rowsPerPage
paginationbooleanEnable page slicing
paginationServerbooleanSkip page slicing (server already paginated)
sortServerbooleanSkip sorting (server already sorted)
sortFunctionSortFunction<T> | nullGlobal custom sort comparator
onSort(col, dir, rows, sortColumns) => voidCalled after sort; receives sorted rows

sortColumns (from tableState.sortColumns) is required — omitting it is a type error.

PropertyTypeDescription
sortedDataT[]All rows after sorting (full dataset, pre-slice)
tableRowsT[]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).

PropertyTypeDescription
filterValuesRecord<string | number, FilterState>Current filter state per column ID
handleFilterChange(columnId, filter) => voidCall 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;
};
OptionTypeDescription
cellNavigationbooleanMaster on/off switch. When false, activeCell is always null.
selectableRowsbooleanReserves nav column 0 for a selection checkbox, if you render one.
expandableRowsbooleanReserves the next nav column for an expander button, if you render one.
expandableRowsHideExpanderbooleanSkip the expander column reservation even when expandableRows is true.
effectiveColumnsTableColumn<T>[]Your current column list. Columns with omit: true are excluded from the nav grid.
filteredTableRowCountnumberRow count for the currently visible page — clamps ArrowDown/End.
showTableHeadbooleanWhether 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;
};
OptionTypeDescription
initialColumnWidthsRecord<string | number, number>Seed widths, e.g. hydrated from localStorage.
onColumnResize(columnId, width, allWidths) => voidFires 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

StateOwned byControlled prop
Current pageuseTableStatepaginationDefaultPage + onChangePage
Rows per pageuseTableStatepaginationPerPage + onChangeRowsPerPage
Selected rowsuseTableStateselectableRowSelected + onSelectedRowsChange
Sort column + directionuseTableStatedefaultSortFieldId + onSort
Column filtersuseColumnFilterfilterValues + onFilterChange
Column widthsuseColumnResizeNone — DOM-only (see recipes)
Column orderuseColumnsonColumnOrderChange
Expanded rowslocal to each row componentexpandableRowExpanded + 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, and handleChangePage; you render the controls
  • Selection UI: tableState.selectedRows, handleSelectedRow, handleSelectAllRows give 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

OptionTypeDefaultDescription
columnsTableColumn<T>[]requiredColumns to include. Columns with omit: true are skipped automatically.
rowsT[]requiredRows to export. Pass the filtered/sorted slice for "current view" semantics.
valueSource'selector' | 'format''selector''selector' exports raw values; 'format' runs column.format first.
headerOverridesRecord<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

PropertyTypeDescription
toCSV() => stringReturns a CSV string.
toJSON() => stringReturns a pretty-printed JSON string.
download(filename: string, format?: 'csv' | 'json') => voidTriggers 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.