@anthony-liddle/react-data-table

中文文档
0.6.1发布于 2026/10/28MITTypeScriptES Module构建来源已验证近 30 天下载 1811

A data-dense React table: sorting, grouped columns, row and drag selection, column management, pagination, and URL + localStorage persistence. Precompiled CSS with documented class names, themed by shadcn-style tokens.

原始使用文档
文档目录17 节
  1. Features
  2. Setup
  3. Quick start
  4. DataTable props
  5. Columns
  6. Hooks
  7. Labels
  8. Persistence
  9. Styling
  10. Theme
  11. Custom properties
  12. Classes and state
  13. Example
  14. Accessibility
  15. Known issues
  16. Development
  17. License

react-data-table

npm CI License: MIT TypeScript React

Storybook: anthony-liddle.github.io/react-data-table. Every feature is an interactive demo there.

npm install @anthony-liddle/react-data-table

A data-dense React table for admin and reporting screens. It was extracted from a production app and published as a standalone package.

Features

  • Sorting: click a header to cycle ascending → descending → off, and opt in to multi-column sort with Shift+click or Shift+Enter. Sort headers are keyboard-operable buttons and report aria-sort.
  • Grouped columns: parent headers spanning sub-columns, with a two-row header.
  • Row selection: checkboxes, select-all, optional click-anywhere-on-the-row, and rows that can't be selected.
  • Drag selection: press on a row and drag to select a range.
  • Column management: show, hide, and reorder columns from a header panel, by mouse or keyboard.
  • Pagination: a footer with range text and an optional "Page N of M", plus hooks for offset and cursor APIs.
  • Persistence: column layout, sort, and filters written to the URL (shareable) and to localStorage (survives reloads).
  • Also: row action menus with submenus, loading skeletons, expandable rows, and overridable labels.

ESM only, strict TypeScript, React 18 or 19. No Tailwind required: the package ships its own CSS.

Upgrading from an earlier version? MIGRATION.md lists every breaking change and what to do.

Setup

Import the stylesheet once, for example in your root layout:

import '@anthony-liddle/react-data-table/styles.css';

That is the whole setup. The table needs no Tailwind and no build step of its own.

Using Tailwind, or any cascade layers of your own? Import the layered copy instead, from your CSS entry, after a line that orders the layers. Between Tailwind's base and utilities, preflight leaves the table alone and a utility you pass through className wins:

@layer theme, base, react-data-table, components, utilities;
@import "tailwindcss";
@import "@anthony-liddle/react-data-table/styles.layer.css";

Import it here rather than from JavaScript, so the order line always comes first. A layer used before it is ordered goes to the bottom, under Tailwind's preflight, and the table loses its padding and borders. With the unlayered styles.css, Tailwind's utilities (which sit in a layer) can't override the table, so the layered copy is the one to use with Tailwind.

styles.layer.css is the same rules as styles.css inside @layer react-data-table. See Styling for theming and overrides.

Quick start

import { DataTable, type TableColumn } from '@anthony-liddle/react-data-table';

interface Member {
  id: string;
  name: string;
  email: string;
  team: string;
}

const columns: TableColumn<Member>[] = [
  { id: 'name', header: 'Name', sortKey: 'name', cell: ({ row }) => row.name },
  { id: 'email', header: 'Email', cell: ({ row }) => row.email },
  { id: 'team', header: 'Team', sortKey: 'team', cell: ({ row }) => row.team, hideByDefault: true },
];

export function Members({ members }: { members: Member[] }) {
  return <DataTable columns={columns} data={members} persistKey="members" />;
}

The table is controlled. You own the data, the sort, the selection, and the page, and the hooks below manage the usual cases. See the Features stories for each one wired up end to end.

DataTable props

PropTypeDefault
columnsTableColumn<T>[]requiredColumn definitions (see below)
dataT[]requiredRows. Each needs a unique id.
sortTableSort–{ sortBy, onSort }: the sorted columns in priority order, and the header click handler. useTableSort returns it.
selectionTableSelection<T>–Selection state and handlers; useTableSelection returns it. Enables checkboxes and drag selection; Shift+click a checkbox, or Shift+Space on one, to set a range.
selectOnRowClickbooleanfalseA click on a row toggles it, except on the controls inside it (see onRowClick)
paginationTablePagination–Renders the pagination footer
showPageCountbooleanfalseAdds "Page N of M" and bordered controls to the footer
actionMenuTableActionMenuItem<T>[]–Per-row menu: links, callbacks, one level of submenus, per-row hidden / disabled
persistKeystring–Saves column visibility and order in localStorage; give the hooks the same key (see Persistence)
hideTableSettingsbooleanfalseHides the column settings panel. It's also left out when every column is pinned (alwaysVisible)
onColumnReorder(order: string[]) => void–Called with the new column id order
onVisibleColumnsChange(columns: VisibleColumn[]) => void–Called when a column is shown or hidden
loadingbooleanfalseShows skeleton rows and sets aria-busy
maxLoadingRowsnumber10Skeleton row count
onRowClick(row: T) => void–Row click callback. Clicks on a control inside the row (a link, button, form control, label, [role=button], [role=checkbox], [role=menuitem] or editable content), or from a portal opened in a cell, don't count. Pair it with a link in the row's main cell for keyboard users (see Accessibility)
renderExpandedRow(row: T) => ReactNode–Content of a row's expanded panel, a full-width row beneath it. Adds a leading column with an expand button per row. Called only for expanded rows
expansionTableExpansion<T>–Which rows are expanded; useTableExpansion returns it. Without it the table keeps that state itself
expandOnRowClickbooleanfalseA click on a row toggles its panel, except on the controls inside it
rowClassName(row: T) => string–Extra classes per <tr>
getRowTestId(row: T) => string | undefined–Per-row data-testid for end-to-end tests
getRowLabel(row: T) => string–Names each row for assistive technology: its checkbox is "Select ⟨label⟩", its menu "Actions for ⟨label⟩" and its expand button "Details for ⟨label⟩". Without it, rows are named by labels.rowLabel, "row ⟨id⟩".
labelsPartial<DataTableLabels>EnglishOverrides any string the table renders or announces (see Labels)
linkComponentComponentType | 'a''a'Renders href menu items, for example a router's Link. It must spread the props it doesn't use onto the anchor and forward its ref, which the menu needs for keyboard focus

Columns

interface TableColumn<T> {
  id: string;                // required, unique in the table, and stable across header text and translations
  header: string;            // header text
  sortKey?: string;          // makes the column sortable
  cell?: ({ row }) => ReactNode;
  loadingCell?: () => ReactNode;
  headerIcon?: ReactNode;
  headerClassName?: string;  // header cell classes
  cellClassName?: string;    // body cell classes
  subColumns?: TableColumn<T>[]; // makes this a grouped parent column
  hideByDefault?: boolean;   // starts hidden; can be shown from the panel
  alwaysVisible?: boolean;   // pinned: can't be hidden or moved, and stays out of the column panel
}

Hooks

HookReturnsUse
useTableSort({ sortableColumns, defaultSortBy, multiSort, persistKey, onChange }){ sortBy, onSort, setSortBy, reset, isRestored }Pass it to the sort prop as it is; send sortBy with your query
useTableSelection(rows, { isRowDisabled }){ selectedRows, onToggleRow, onToggleAll, clearSelection, … }Pass it to the selection prop as it is
useTableExpansion({ isRowExpandable, initialExpandedIds }){ isRowExpanded, onToggleRow, expandedIds, setExpandedIds, collapseAll, … }Pass it to the expansion prop as it is, to keep rows open across a refetch or open one from outside
usePagination({ pageSize, persistKey }){ offset, limit, pageIndex, getPagination, reset, isRestored, … }Offset or page-number APIs and arrays: send offset / limit, pass getPagination(totalCount) to the pagination prop
useCursorPagination({ pageSize }){ variables, pageIndex, getPagination, reset }GraphQL connections: send variables ({ first, after }) with the query, pass getPagination(pageInfo, totalCount?) to the pagination prop, with the page info of the page variables asked for
usePersistedFilters(key, filterDefs, { persistToStorage }){ filters, setFilter, reset, isRestored }Any filter state, persisted like the table

sortBy lists the sorted columns in priority order, each { key, direction } with a lowercase 'asc' or 'desc'; unsorted is an empty list. If your API wants an uppercase enum, convert at the call. reset() returns to defaultSortBy, and setSortBy([]) clears the sort.

With multiSort: true, Shift+click on a header (or Shift+Enter or Shift+Space on a focused one) adds that column as the next sort key, then flips it, then removes it, leaving the others in place. A plain click still makes the column the only sort. Each sorted column shows a small number with its place while two or more are sorted. Turn it on only when your API can sort by several fields.

To keep the selection in your own state instead of useTableSelection, pass isRowSelected, onToggleRow and onToggleAll to the selection prop yourself. A Shift range or a drag calls onToggleRow once for each row it changes, in a single event, so update your state from its previous value (setSelected(prev => ...)). Otherwise only the last row sticks.

usePagination hands back the offset and limit for the current page. The total row count usually arrives with that page, so the table's pagination prop is built from it at render time:

const { offset, limit, getPagination, reset } = usePagination({ pageSize: 25 });
const { items, total } = useMembers({ offset, limit }); // your fetch, or rows.slice(offset, offset + limit)

<DataTable columns={columns} data={items} pagination={getPagination(total)} />;

Call reset() when the sort or filters change, so the table goes back to the first page. useTableSort's onChange runs on every sort change except restoring a persisted one, so it's the place for that: useTableSort({ onChange: () => reset() }).

TablePagination.totalCount is optional, since cursor APIs often have no total. Without it the footer reads "Showing 11-20", ending at the page's row count (DataTable supplies it; a standalone Pagination takes pageRowCount), it drops "Page N of M", and Next follows hasNextPage. A negative or non-finite total, such as an API's -1 for unknown, counts as none. usePagination works out hasNextPage from the total, so it needs the real one. With an unknown total, use useCursorPagination for a cursor API, or build the pagination prop yourself with your API's own hasNextPage. A page with no rows reads "Showing 0-0".

Labels

Every string the table renders or announces comes from labels, so a translation covers screen reader names too. Pass any subset to DataTable or Pagination; the rest keep their English defaults, which defaultLabels exports.

KeyDefault
noResultsNo results found
showingRangeOf({ start, end, total })Showing {start}-{end} of {total} results
selectAllSelect all
selectRow({ rowLabel })Select {rowLabel}
rowLabel({ id })row {id} (used when getRowLabel isn't set)
rowActions({ rowLabel })Actions for {rowLabel}
columnSettingsColumn settings
columnsPanelColumns
reorderColumn({ column })Reorder {column}
previousPage / nextPagePrevious page / Next page
showingRange({ start, end })Showing {start}-{end} (when the total is unknown)
pageOf({ page, pageCount })Page {page} of {pageCount}
expandColumnDetails (the expand column's hidden header text)
expandRow({ rowLabel })Details for {rowLabel}
sortPriority({ priority, direction })sort priority {priority}, ascending / descending

A standalone TableActionMenu names its trigger "Actions", or triggerLabel.

Persistence

Give the table and its hooks the same persistKey. What is worth sharing goes in the URL, and the column layout stays in the browser:

StateOwnerURLlocalStorage
SortuseTableSort{key}.sort=name,-teamrdt:1:{key}:sort
PageusePagination{key}.page=3none
FiltersusePersistedFilters{key}.{param}=…rdt:1:{key}:filters
Column visibility and orderDataTablenonerdt:1:{key}:columns
  • A link is the whole view. When a page opens with params in a table's namespace that it didn't write itself, those params alone set the view: a state the link leaves out takes its default, and saved state isn't read. Otherwise each state reads its own param, then localStorage, then its default. The column layout is never part of a link, so it always comes from localStorage.
  • The URL shows the current view. Once restored, and on every change, state is written back with history.replaceState, keeping your other params, the hash and history.state. The sort is always written, as {key}.sort= when unsorted, so the address bar gains the table's params on first load.
  • Readable, checked one param at a time. A sort is a comma list in priority order, - marking descending, and the page is 1-based and absent on page 1. A param that doesn't parse, or a sort naming a column outside sortableColumns, takes its default while the others still apply. A page past the end is kept: the table shows its empty state and Previous still works. Storage keys carry a format version (rdt:1:), so a later format ignores old entries.
  • Names. Sort keys can't contain commas or start with -, and filter param names can't contain .. Give each table on a page its own key, and use one usePersistedFilters per key.
  • Pass sortableColumns with a persistKey. A link can name any sort key, and without the list it goes straight into your query; with it, an unknown key falls back to the default.
  • Server rendering. The server render, and the client render that hydrates it, use the defaults, so persisted state never causes a hydration mismatch; it arrives in the render straight after. A client-only render starts from it. Each hook returns isRestored, false only during those first renders, so you can hold a fetch until the persisted state is in.

Styling

Every element the table renders has a documented class, and runtime state lives on attributes, so you restyle it with ordinary CSS. Each base rule in styles.css is a single class, and a modifier or state adds at most one more class, attribute or pseudo-class. So one class of yours, loaded after the stylesheet, overrides a base style, and the same class with the same state overrides a stateful one. Nothing uses !important, and the stylesheet resets only its own elements, never your markup inside a cell.

Theme

Colours come from shadcn-style tokens, read as full colour values: --background, --foreground, --card, --popover, --popover-foreground, --primary, --primary-foreground, --muted, --muted-foreground, --accent, --accent-foreground, --border and --ring. The package never declares them. It reads each one with a built-in default, so a theme you declare anywhere wins, even inside a cascade layer, and .dark on any ancestor, or on a component's own element, switches the defaults. Menus and the column panel render at the end of <body>, so when their table sits in a .dark section they copy .dark onto themselves. A current shadcn/ui theme applies as it is.

Older shadcn themes wrote bare HSL triplets (--card: 0 0% 100%) and read them as hsl(var(--card)). To use one here, change both together: write each value as a full colour (--card: hsl(0 0% 100%)), and read it as var(--card) everywhere in your own CSS, including a Tailwind @theme mapping. Changing only the values gives hsl(hsl(...)), which is invalid.

Custom properties

Set these on :root, on any ancestor of the table, or on a single row through rowClassName.

PropertyDefault
--rdt-radius0.5remCorner radius of the table container
--rdt-font-size0.875remBody text
--rdt-header-font-size0.75remHeader text
--rdt-cell-padding-x0.75remHorizontal cell padding
--rdt-cell-padding-y0.5remVertical cell padding
--rdt-row-selectedunsetBackground tint for selected rows. Selected rows always show a 3px bar in --primary on their first cell; the tint is extra. Keep your cell text readable against it: with shadcn's default muted text, a light tint has to stay very pale to reach 4.5:1.
--rdt-skeletonstone, light and darkLoading placeholders
--rdt-drag-selectionhsl(216 100% 58.4%)The bar beside rows in a drag range

Classes and state

BlockElements and modifiersState
rdt-table__scroll, __table, __head, __header-row, __header-cell (--group, --expand, --select, --settings, --actions, --sticky), __header-label, __select-all, __body, __row (--clickable), __cell (--expand, --select, --actions, --settings, --sticky), __empty-row, __empty, __expanded-row, __expanded-cellaria-busy and data-dragging on __table; data-selected, data-drag-range, data-drag-start and data-drag-end on __row
rdt-sort-header (--sticky)__button (spans the cell), __content (follows the header's text-align), __label, __icon, __arrow, __priority (a sorted column's place, while two or more are sorted)aria-sort on the header; data-direction="asc" or "desc" on __icon
rdt-checkbox__indicator, __icondata-state, data-disabled
rdt-column-panel__trigger, __trigger-icon, __list, __item, __label, __handle, __handle-icondata-dragging on __item
rdt-menu (--sub)__item, __sub-trigger, __chevrondata-highlighted, data-disabled, data-state
rdt-action-menu__trigger, __trigger-icon, __link, __content, __icon (--link)
rdt-pagination (--bordered)__range, __controls, __page-count, __button, __icon
rdt-button (--ghost, --outline):disabled
rdt-skeleton (--select, --action, --cell)
rdt-expand-button__icon (the chevron, turned while open)aria-expanded
rdt-visually-hiddenText for screen readers only

Inside DataTable, a sortable header is both rdt-table__header-cell and rdt-sort-header. The column panel and the row menus render in a portal at the end of <body>, so style them by their own classes, not through .rdt-table.

Example

/* Denser rows and square corners for every table on the page */
:root {
  --rdt-cell-padding-y: 0.25rem;
  --rdt-radius: 0;
}

/* Tint selected rows as well, in both themes. These keep shadcn's
   default muted text at 4.5:1. */
:root {
  --rdt-row-selected: oklch(0.985 0.012 250);
}

.dark {
  --rdt-row-selected: oklch(0.26 0.03 255);
}

/* Bold the sorted column's header */
.rdt-table__header-cell[aria-sort] {
  font-weight: 600;
}

Accessibility

On every PR, each story is rendered in headless Chromium and checked with axe (@storybook/addon-a11y).

  • Clickable rows: rows don't take focus, since a focused <tr> can't announce that it does anything. For a row that opens something, put a real link (or button) in its main cell for keyboard and screen reader users, and use onRowClick as the mouse shortcut for the rest of the row. Clicks on the link don't count as row clicks, so nothing fires twice. The Clickable Rows story shows it.
  • Expandable rows have a real button, named "Details for ⟨label⟩", with aria-expanded and, while open, aria-controls pointing at the panel.
  • Sort headers are buttons. The primary sorted column carries aria-sort; while two or more columns are sorted, each sorted header also announces its place and direction ("sort priority 2, descending").
  • Header cells use scope="col", and grouped parent headers use scope="colgroup".
  • Checkboxes, pagination buttons ("Previous page" / "Next page"), the column settings trigger, and column drag handles ("Reorder ⟨column⟩") all have accessible names.
  • Row checkboxes and row menus are named after their row ("Select ⟨label⟩", "Actions for ⟨label⟩") when you pass getRowLabel, for example the row's name. Without it they fall back to "row ⟨id⟩", so pass it whenever rows have a human-readable name.
  • The column settings panel is a dialog: Tab reaches every checkbox and handle, Space toggles a column, and a focused handle reorders with Space, the arrow keys, and Space.
  • Row menus are Radix dropdown menus with arrow-key navigation.

Known gaps:

  • Drag selection and click-to-select rows are mouse-only. The checkboxes are the keyboard path, and Shift+Space on a focused checkbox sets every row from the last one toggled.

Known issues

None at the moment. Report one on GitHub.

Development

pnpm install
pnpm storybook        # http://localhost:6006
pnpm test             # unit tests (Vitest + happy-dom)
pnpm test:storybook   # every story in Chromium: play functions + axe
pnpm lint             # Biome
pnpm typecheck
pnpm build            # dist/
pnpm lint:package     # publint + are-the-types-wrong
pnpm smoke            # install the packed tarball and use it

pnpm test:storybook needs Chromium once: pnpm exec playwright install chromium.

Releases publish from CI when a GitHub Release is published, via npm trusted publishing with provenance. See CONTRIBUTING.md.

License

MIT © Anthony Liddle

版本与发布记录

@anthony-liddle/react-data-table 共发布 10 个版本,首次发布于 2026/10/23;过去 12 个月发布了 10 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.6.1latest2026/10/2811 天前
0.6.02026/10/2613 天前
0.5.12026/10/2514 天前
0.5.02026/10/2514 天前
0.4.02026/10/2415 天前
0.3.02026/10/2415 天前

关于 @anthony-liddle/react-data-table 的常见问题

@anthony-liddle/react-data-table 是什么?

A data-dense React table: sorting, grouped columns, row and drag selection, column management, pagination, and URL + localStorage persistence. Precompiled CSS with documented class names, themed by shadcn-style tokens.

如何安装 @anthony-liddle/react-data-table?

在项目目录运行 npm install @anthony-liddle/react-data-table,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:react(^18.0.0 || ^19.0.0)、react-dom(^18.0.0 || ^19.0.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@anthony-liddle/react-data-table 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@anthony-liddle/react-data-table 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@anthony-liddle/react-data-table 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=20。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@anthony-liddle/react-data-table 可以商用吗?

@anthony-liddle/react-data-table 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@anthony-liddle/react-data-table 的下载量有多少?

根据 npm 官方下载统计,@anthony-liddle/react-data-table 在 2026/10/06 – 2026/11/04 这 30 天内被下载 1,811 次,日均约 60 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@anthony-liddle/react-data-table 还在维护吗?

最新版本 0.6.1 发布于 2026/10/28(11 天前);至今共发布 10 个版本,其中过去 12 个月发布了 10 个;首次发布于 2026/10/23。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。