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

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 (pointer-driven: mouse, touch, and pen)
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

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

StateOwned byControlled prop
Current pageuseTableStatepaginationDefaultPage + onChangePage
Rows per pageuseTableStatepaginationPerPage + onChangeRowsPerPage
Selected rowsuseTableStateselectableRowSelected + onSelectedRowsChange
Sort column + directionuseTableStatedefaultSortFieldId + onSort
Column filtersuseColumnFilterfilterValues + onFilterChange
Column widthsuseColumnResizeinitialColumnWidths + onColumnResize
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

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 KimEngineering$162,000
Aria ChenEngineering$155,000
Casey MorganHR$88,000
Drew ParkAnalytics$99,000
Jamie OkaforEngineering$141,000
Page 1 of 3 · 12 of 12 rows
Rows per page: