Skip to content

Data table

Shows records in a table that sorts, selects, runs row actions and loads more. Formats typed columns: currency, percent, dates, links, addresses.

Actions
Nov 14, 2026
Value Proposition
$140,000.00
Oct 30, 2026
Closed Won
$235,000.00
Dec 5, 2026
Qualification
$15,000.00
Jan 18, 2027
Id. Decision Makers
$75,000.00
Nov 2, 2026
Prospecting
$60,000.00

Installation

pnpm dlx shadcn@latest add @wisty/data-table

The CLI installs the Radix or Base UI file to match your project, with what it builds on: button, checkbox, dropdown-menu, output-field, skeleton, spinner, table.

If your project doesn't have Wisty UI's theme yet, add it once with npx shadcn@latest add @wisty/cosmos. Until then, components keep shadcn's default look. Installation has the details.

When to use

Use it

  • People read, sort and act on a list of records, like a list view of opportunities or a related list of contacts.
  • People select rows for a bulk action, like deleting or reassigning several records, or each row has its own actions, like Edit and Delete.
  • Values are Salesforce field types: currency, percent, numbers, dates, date-times, times of day, relative times, URLs, emails, phone numbers, checkboxes, addresses and names. Set meta.type and the table formats, aligns and sorts them.
  • A column needs its own format, like a currency's code, a precise percent, a short date or a URL shown as a label in a new tab, as lightning-datatable's typeAttributes set it.
  • The records come in pages, like a GraphQL query with a cursor. The table shows Load more, or loads on scroll.
  • The list runs to hundreds or thousands of rows. Give the table a height and pass virtualize.
  • It's the table lightning-datatable draws: Salesforce's column types, resizable columns, row actions, loading more and virtualization.

Don't use it

  • The data is a few read-only rows that don't sort or select, like a summary in a card. Use Table, which is plain markup.
  • People edit cells in place or move between cells with the arrow keys. The data table doesn't do either yet. Inline edit and grid keyboard navigation are planned.
  • People filter, hide, pin or group columns. Put filters in your own toolbar above the table and filter data yourself. The table has no column visibility, pinning or grouping.
  • People page through numbered pages. Salesforce tables load more instead of paging, so there are no page controls. If you need them, put Pagination below the table and pass one page as data.
  • The rows are a tree. The data table has no nested rows.

Usage

The page owns the table. Call useDataTable in a client component, with columns built by createDataTableColumnHelper, and render <DataTable table={table} />. The page reads the selection and handles the actions from the same table, so its own buttons sit beside the table, as the Selection and Row actions examples show.

"use client"

import {
  createDataTableColumnHelper,
  DataTable,
  useDataTable,
} from "@/components/wisty/data-table"
type Opportunity = { id: string; name: string; amount: number | null }

// Outside the component, so the columns keep their identity.
const col = createDataTableColumnHelper<Opportunity>()
const columns = col.columns([
  col.accessor("name", {
    header: "Opportunity name",
    meta: { rowHeader: true },
  }),
  col.accessor("amount", { header: "Amount", meta: { type: "currency" } }),
])

export function OpportunitiesTable({
  opportunities,
}: {
  opportunities: Opportunity[]
}) {
  const table = useDataTable({
    columns,
    data: opportunities,
    getRowId: (row) => row.id,
  })
  return <DataTable table={table} aria-label="Opportunities" />
}

Columns are TanStack Table column definitions; meta takes the data table's own keys, listed in the API reference. Sorting, selection and column widths are TanStack state: pass initialState to start sorted or sized, or state with its change callback to control them (the Sorting and Resizing examples). A callback alone works too: the table keeps the state and calls you after each change.

Salesforce's GraphQL API returns fields as { value, displayValue }. Read .value with a meta.type, so the table formats and sorts the raw value, as an accessor function with an id:

col.accessor((row) => row.Amount.value, {
  id: "Amount",
  header: "Amount",
  meta: { type: "currency", currencyCode: "EUR" },
})

A column's meta also takes Output field's format and link keys, so a column reads as a record's field of the same type. The cell's title and the sort keep the value:

col.accessor("website", {
  header: "Website",
  meta: { type: "url", linkLabel: "Visit site", linkTarget: "_blank" },
})

A label that differs by row, like each account's name on its website, is a custom cell that draws OutputFieldCompactValue from @/components/wisty/output-field:

col.accessor("website", {
  header: "Website",
  meta: { type: "url" },
  cell: ({ row, getValue }) => (
    <OutputFieldCompactValue
      type="url"
      value={getValue()}
      linkLabel={row.original.name}
      linkTarget="_blank"
    />
  ),
})

showRowNumberColumn numbers the rows before the checkboxes, as list views do. The numbers count the rows shown, so sorting or filtering data renumbers them from 1. When data is one page of a longer list, like the third page of ten, pass the rows before it as rowNumberOffset and the first row reads 21.

To sort on the server, pass manualSorting and an onSortingChange that fetches; the state carries each column's id, like [{ id: "Amount", desc: true }]. To add your own meta keys, merge into DataTableColumnMeta with declare module "@/components/wisty/data-table".

Examples

Sorting

Sorted by amount, highest first, with `initialState.sorting` and the empty amount last, and a click on a header sorts by it.

Sorted by Amount, descending
Burlington Textiles Weaving Plant Generator
Burlington Textiles Corp of America
Oct 30, 2026
$235,000.00
Acme - 1,200 Widgets
Acme
Nov 14, 2026
$140,000.00
Edge Emergency Generator
Edge Communications
Jan 18, 2027
$75,000.00
GenePoint Lab Generators
GenePoint
Nov 2, 2026
$60,000.00
Dickenson Mobile Generators
Dickenson plc
Dec 5, 2026

Selection

Shift ranges and select all, capped at three rows by `maxRowSelection`, closed opportunities disabled by `enableRowSelection`, and a Delete button.

0 of 3 selected
Acme - 1,200 Widgets
Value Proposition
$140,000.00
Burlington Textiles Weaving Plant Generator
Closed Won
$235,000.00
Dickenson Mobile Generators
Qualification
$15,000.00
Edge Emergency Generator
Id. Decision Makers
$75,000.00
GenePoint Lab Generators
Prospecting
$60,000.00

Row numbers

A number before each row's checkbox from `showRowNumberColumn`, counting rows in the order shown, so sorting renumbers them.

Row Number
1
Burlington Textiles Weaving Plant Generator
Burlington Textiles Corp of America
Oct 30, 2026
$235,000.00
2
Dickenson Mobile Generators
Dickenson plc
Dec 5, 2026
$15,000.00
3
Edge Emergency Generator
Edge Communications
Jan 18, 2027
$75,000.00
4
Express Logistics Standby Generator
Express Logistics and Transport
Nov 21, 2026
$220,000.00
5
GenePoint Lab Generators
GenePoint
Nov 2, 2026
$60,000.00
6
Grand Hotels Kitchen Generator
Grand Hotels & Resorts Ltd
Dec 12, 2026
$15,000.00
7
Pyramid Emergency Generators
Pyramid Construction Inc.
Feb 3, 2027
$100,000.00
8
United Oil Installations
United Oil & Gas Corp.
Nov 9, 2026
$270,000.00
9
University of AZ Portable Generators
University of Arizona
Jan 7, 2027
$50,000.00
10
sForce SLA
sForce
Dec 19, 2026
$120,000.00

Single selection

Radios for picking one contact, with no select all, set with `maxRowSelection: 1`.

Choose a row to select
Rose Gonzalez
SVP, Procurement
Sean Forbes
CFO
Jack Rogers
VP, Facilities
Pat Stumuller
SVP, Administration and Finance

Row actions

A `rowActions` function builds each row's menu when it opens, with Delete disabled on closed opportunities, and `onRowAction` handles the choice.

Actions
Acme - 1,200 Widgets
Value Proposition
$140,000.00
Burlington Textiles Weaving Plant Generator
Closed Won
$235,000.00
Dickenson Mobile Generators
Qualification
$15,000.00
Edge Emergency Generator
Id. Decision Makers
$75,000.00

Column types

Every `meta.type`, time included, with an amount's code from `currencyDisplay`, a short `dateStyle` and a URL as its `linkLabel` in a new tab.

Acme - 1,200 Widgets
USD 140,000.00
60%
1,200
11/14/26
Sep 2, 2026, 10:10 AM
, Oct 13, 2026, 8:00 AM
Edge Emergency Generator
USD 75,000.50
25%
1/18/27
Aug 21, 2026, 9:30 AM
, Oct 12, 2026, 10:00 AM
Rose Gonzalez
9:30 AM
312 Constitution Place, Austin, TX 78767, USA
True
Jack Rogers
2:00 PM
525 S. Lexington Ave, Burlington, NC 27215, USA

Wrap and clip

Long text clipped with a title, or wrapped to three lines with `meta.wrapText`, switched from header menus built with `meta.headerActions`.

Burlington Textiles Corp of America
Textile maker with plants in North Carolina and Georgia. Replacing its backup power across three weaving plants before the winter peak.
Send the revised quote for the third plant and confirm the install window with facilities.
Edge Communications
Regional carrier. Standby generators for two exchanges.
Site survey on Thursday.
United Oil & Gas Corp.
Refinery upgrade with a long approval chain. Procurement reviews every vendor each quarter.
Wait for procurement's review, then schedule a call with the plant manager and the CFO.

Resizing

Resizing within `minSize` and `maxSize`, an amount fixed by `enableResizing: false`, and widths the page keeps in `state.columnSizing` and resets.

Acme
Value Proposition
$140,000.00
Burlington Textiles Corp of America
Closed Won
$235,000.00
Dickenson plc
Qualification
$15,000.00
Edge Communications
Id. Decision Makers
$75,000.00
GenePoint
Prospecting
$60,000.00

Loading and empty

Placeholder rows from `isLoading` while the first page loads, and the page's own words in `empty` when nothing matches.

No opportunities match your search.

Load more

Five opportunities and a Load more button for the next five, from `hasNextPage` and `onLoadMore`, keeping the selection.

Acme Generators 1
Prospecting
$10,000.00
Burlington Textiles Generators 2
Qualification
$47,000.00
Dickenson Generators 3
Proposal
$84,000.00
Edge Communications Generators 4
Negotiation
$121,000.00
Express Logistics Generators 5
Prospecting
$158,000.00

Load on scroll

A table with a height that scrolls under its header and, with `loadMoreOnScroll`, loads the next page as its last rows come into view.

Acme Generators 1
Prospecting
$10,000.00
Burlington Textiles Generators 2
Qualification
$47,000.00
Dickenson Generators 3
Proposal
$84,000.00
Edge Communications Generators 4
Negotiation
$121,000.00
Express Logistics Generators 5
Prospecting
$158,000.00
GenePoint Generators 6
Qualification
$195,000.00
Grand Hotels Generators 7
Proposal
$232,000.00
Pyramid Construction Generators 8
Negotiation
$19,000.00
Acme Generators 9
Prospecting
$56,000.00
Burlington Textiles Generators 10
Qualification
$93,000.00
Dickenson Generators 11
Proposal
$130,000.00
Edge Communications Generators 12
Negotiation
$167,000.00
Express Logistics Generators 13
Prospecting
$204,000.00
GenePoint Generators 14
Qualification
$241,000.00
Grand Hotels Generators 15
Proposal
$28,000.00
Pyramid Construction Generators 16
Negotiation
$65,000.00
Acme Generators 17
Prospecting
$102,000.00
Burlington Textiles Generators 18
Qualification
$139,000.00
Dickenson Generators 19
Proposal
$176,000.00
Edge Communications Generators 20
Negotiation
$213,000.00

Virtualized

10,000 rows with selection and row menus, rendering only the rows in view with `virtualize`.

Actions
$5,000.00
Backup power for Acme: three sites, installed in phases over two quarters, with a service plan for each.
$12,919.00
Standby generator.
$20,838.00
Standby generator.
$28,757.00
Standby generator.
$36,676.00
Standby generator.
$44,595.00
Backup power for GenePoint: three sites, installed in phases over two quarters, with a service plan for each.
$52,514.00
Standby generator.
$60,433.00
Standby generator.
$68,352.00
Standby generator.
$76,271.00
Standby generator.
$84,190.00
Backup power for Acme: three sites, installed in phases over two quarters, with a service plan for each.
$92,109.00
Standby generator.
$100,028.00
Standby generator.
$107,947.00
Standby generator.
$115,866.00
Standby generator.
$123,785.00
Backup power for GenePoint: three sites, installed in phases over two quarters, with a service plan for each.
$131,704.00
Standby generator.
$139,623.00
Standby generator.
$147,542.00
Standby generator.
$155,461.00
Standby generator.
$163,380.00
Backup power for Acme: three sites, installed in phases over two quarters, with a service plan for each.
$171,299.00
Standby generator.
$179,218.00
Standby generator.
$187,137.00
Standby generator.
$195,056.00
Standby generator.
$202,975.00
Backup power for GenePoint: three sites, installed in phases over two quarters, with a service plan for each.
$210,894.00
Standby generator.
$218,813.00
Standby generator.
$226,732.00
Standby generator.
$234,651.00
Standby generator.

API reference

DataTable

PropTypeDefaultDescription
table*DataTableInstance<TData, any>The table from useDataTable.
isLoadingbooleanfalseA first load: placeholder rows while there are none.
hasNextPagebooleanfalseMore rows can load: shows Load more, or loads on scroll.
isFetchingNextPagebooleanfalseA next page is loading.
onLoadMore() => voidLoads the next page. The page appends its rows to data.
loadMoreOnScrollbooleanfalseLoads the next page on scroll instead of with the button, which stays as a retry.
virtualizeboolean | { overscan?: number; estimateRowHeight?: number }falseRenders only the rows in view. Needs a height on DataTable.
emptyReact.ReactNodeWhat the table shows when it has no rows.

Also takes every <table> (opens in a new tab) prop.

useDataTablehook

ParameterTypeDefault
options*UseDataTableOptions<TData>
selector(state: TableState<Features>) => TSelected

Returns DataTableInstance<TData, TSelected>.

createDataTableColumnHelperfunction

Returns DataTableColumnHelper<TData>.

DataTableColumnTypetype

type DataTableColumnType = OutputFieldType

DataTableRowActiontype

An item in a row's actions menu.

MemberTypeDefaultDescription
value*stringIdentifies the action in onRowAction: "edit", "delete".
label*stringThe item's text.
disabledbooleanShows the item but doesn't let it run.

DataTableHeaderActiontype

An item in a column header's actions menu.

MemberTypeDefaultDescription
value*stringIdentifies the action in onHeaderAction: "wrap", "clip".
label*stringThe item's text.
disabledbooleanShows the item but doesn't let it run.
checkedbooleanShows a check before the label when true, and room for one when false.

DataTableColumnMetatype

What a column's meta takes. To add your own keys, edit this interface, or merge into it with declare module "@/components/wisty/data-table".

MemberTypeDefaultDescription
typeDataTableColumnTypeHow values format, align and sort. Defaults to "text".
currencyCodestringISO 4217 code for "currency" columns. Defaults to "USD".
labelstringThe column's name in menus and announcements when header is a function.
rowHeaderbooleanRenders cells as row headers (<th scope="row">), which name each row.
wrapTextbooleanWraps long values instead of clipping them.
wrapTextMaxLinesnumberWith wrapText, the lines a cell shows before it clips.
align"start" | "center" | "end"Overrides the type's alignment. Numbers align to the end.
headerClassNamestringClasses on the column's header cell.
cellClassNamestringClasses on the column's body cells.
headerActionsreadonly DataTableHeaderAction[]An actions menu in the column header, handled by onHeaderAction.
fractionDigitsnumberDigits after the decimal point, a field's scale, as both the fewest and the most. Unset, Intl's default.
minimumFractionDigitsnumberThe fewest digits after the decimal point, over fractionDigits.
maximumFractionDigitsnumberThe most digits after the decimal point, over fractionDigits; under the minimum, it reads as the minimum.
currencyDisplay"symbol" | "code" | "name"A currency as its symbol ("$"), its code ("USD") or its name ("US dollars"). Defaults to "symbol".
dateStyleOutputFieldDateStyleThe day's style in "date" and "datetime" values. Defaults to "medium": "Jun 18, 2026".
timeStyleOutputFieldDateStyleThe time's style in "datetime" and "time" values. Defaults to "short": "3:30 PM".
hour12booleantrue for a 12-hour clock, false for 24 hours. Unset, the locale's own clock.
timeZoneName"short" | "long"The zone's name after a "datetime" value, "EDT" or "Eastern Daylight Time". Unset, none.
linkLabelstringWords the link shows instead of the value, like "Visit website". The value stays its address.
linkTarget"_blank" | "_self" | "_parent" | "_top"Where the link opens; "_blank" adds rel="noopener noreferrer". Defaults to the same tab.
linkTitlestringThe link's hover text.

Featurestype

MemberTypeDefault
rowSortingFeature*TableFeature
rowSelectionFeature*TableFeature
columnSizingFeature*TableFeature
columnResizingFeature*TableFeature
dataTableFeature*TableFeature
sortedRowModel*(table: Table<any, any>) => () => RowModel<any, any>
columnMeta*DataTableColumnMeta

DataTableRowtype

type DataTableRow<TData extends RowData> = Row<Features, TData>

DataTableColumntype

type DataTableColumn<TData extends RowData> = Column<Features, TData, unknown>

DataTableColumnDeftype

type DataTableColumnDef<TData extends RowData, TValue = unknown> = ColumnDef<
  Features,
  TData,
  TValue
>

DataTableInstancetype

type DataTableInstance<
  TData extends RowData,
  TSelected = TableState<Features>,
> = ReactTable<Features, TData, TSelected>

DataTableColumnHelpertype

TanStack's column helper for this table, except that a column with an accessor function needs an id: it's the id the sorting state carries.

type DataTableColumnHelper<TData extends RowData> = Omit<
  ColumnHelper<Features, TData>,
  "accessor"
> & {
  accessor: <
    TAccessor extends AccessorFn<TData> | DeepKeys<TData>,
    TValue extends (TAccessor extends AccessorFn<TData, infer TReturn>
      ? TReturn
      : TAccessor extends DeepKeys<TData>
        ? DeepValue<TData, TAccessor>
        : never),
  >(
    accessor: TAccessor,
    column: TAccessor extends AccessorFn<TData>
      ? DisplayColumnDef<Features, TData, TValue> & { id: string }
      : IdentifiedColumnDef<Features, TData, TValue>
  ) => TAccessor extends AccessorFn<TData>
    ? AccessorFnColumnDef<Features, TData, TValue>
    : AccessorKeyColumnDef<Features, TData, TValue>
}

UseDataTableOptionstype

type UseDataTableOptions<TData extends RowData> = Omit<
  TableOptions<Features, TData>,
  "features" | "getRowId"
> & {
  getRowId: (
    originalRow: TData,
    index: number,
    parent?: DataTableRow<TData>
  ) => string
}

SLDS mapping

DataTable is the SLDS table as lightning-datatable renders it, on Table, with no cell buffer. Row headers are th scope="row", and cells that don't wrap clip with slds-truncate. The row number column is lightning-datatable's, each number slds-row-number in small, weak text. The select column holds Checkbox, and the actions button opens Dropdown Menu. The on-scroll spinner is SLDS's small spinner. SLDS has no Load more button or placeholder rows: Load more is slds-button_neutral, and placeholder rows are Skeleton.

Known gaps:

  • No grid: the table isn't role="grid" and has no arrow keys between cells. Every control is a tab stop.
  • A resize's guide line stops at the bottom of the visible rows. SLDS draws it 100vh tall.
  • After a resize, widths stay in pixels when the container changes size, so the table scrolls sideways or leaves space at its end. lightning-datatable scales the columns to fit again.
  • Wrap text and Clip text aren't built into header menus, as lightning-datatable adds them. The Wrap and clip example builds them with meta.headerActions.
  • The arrow, chevrons and check are lucide's, where SLDS draws utility:arrowdown, utility:down and utility:check.
  • The text the table adds ("Row Number", "Select all rows", "Choose a row to select", "Select …", "Show actions for …", "Show … column actions", "Sorted by …", "No items to display.", "Load more", "Loading") is English.
PropValueSLDSNotes
(no props)default (added)slds-table slds-table_bordered slds-table_fixed-layout, slds-is-sortable, slds-th__actionFixed layout: columns with a size keep it, the rest share the width. 8px in every cell, the row's ends too, as lightning-datatable has no cell buffer. Every header with data sorts.
enableColumnResizingtrue (default) (added)slds-table_resizable-cols, slds-is-resizable, slds-resizable__handle, slds-resizable__divider, slds-resizable__inputA divider at each data header's end that drags, or follows the mouse after a click, and a range input that the arrow keys move 10px. After a resize the other columns keep their widths. false turns it off.
enableResizingfalse (column) (added)lightning-datatable fixedWidthThe column keeps its size. The select and actions columns never resize.
minSize, maxSize(px) (added)lightning-datatable min-column-width, max-column-widthA column's bounds, 50px and 1000px by default.
showRowNumberColumntrue (added)lightning-datatable show-row-number-column, slds-row-number slds-text-body_small slds-text-color_weakA 3.25rem first column of row numbers in small weak text, 20px in, wider from three digits. rowNumberOffset sets the number before the first row.
enableRowSelectiontrue (added)slds-checkbox in lightning-datatable's 2rem column, slds-is-selectedA checkbox per row with Shift ranges, and a select-all checkbox with SLDS's mixed dash.
maxRowSelection(n) (added)lightning-datatable max-row-selectionCaps single, Shift-range and select-all changes in display order. At the cap the other checkboxes are disabled.
maxRowSelection1 (added)slds-radio, Choose a row to selectRadios with one tab stop instead of checkboxes, and no select all.
rowActions(set) (added)slds-button_icon-border slds-button_icon-x-small, slds-dropdownA 50px column with a 20px button in the middle of each row's cell that opens a menu.
meta.typenumber, currency, percent (added)slds-text-align_rightFormatted with Intl and aligned to the end. Other types align to the start.
meta.wrapTexttrue (added)slds-cell-wrap, slds-line-clampWraps instead of clipping (slds-truncate with a title). wrapTextMaxLines clamps it.
meta.headerActions(set) (added)slds-has-button-menu, slds-th__action-buttonA 20px menu button 12px from the header cell's end.
classNameh-* or max-h-* (added)slds-table_header-fixed_container, slds-table_header-fixedThe table scrolls inside its container, under sticky header cells.
isLoadingtrue (added)(none)Placeholder rows on a first load, and aria-busy.
onLoadMore(set) (added)(none)A neutral Load more button below the table while hasNextPage is true.
loadMoreOnScrolltrue (added)lightning-datatable enable-infinite-loadingLoads the next page as the last rows come into view, with SLDS's spinner below them.
virtualizetrue (added)(none)Renders only the rows in view. Needs a height.

Keyboard interactions

Keys Action
Space On a checkbox, selects or clears its row. On select all, selects every loaded row, or clears them.
Shift+Space, Shift+click On a row's checkbox, selects or clears every row from the last one you checked, rows out of view included.
Up Arrow, Down Arrow On a radio, selects the row above or below.
Enter, Space On a sort button, sorts by the column, then reverses it. On an actions button, opens its menu.
Arrow keys On a column divider, widen or narrow the column by 10px. Left widens in a right-to-left table.
Page Up, Page Down On a column divider, widen or narrow the column by 100px.
Home, End On a column divider, make the column its narrowest or widest.
Escape While you drag a divider, or move one after clicking it, puts the column back.

Tab moves through select all, each sort button, header menu button and column divider, then each row's checkbox, links and actions button. With virtualize, the focused row and its neighbours stay rendered, so Tab continues in order after you scroll away, and a row stays while its menu is open. Menus take Dropdown Menu's keys.

Accessibility

  • Name the table with aria-label or aria-labelledby on DataTable.
  • Mark the name column meta.rowHeader, or pass getRowLabel. Its value names each row's controls: "Select Acme", "Show actions for Acme". Without one, they name the row by its position: "Select row 1".
  • Give a column whose header is a function a meta.label. It names the sort button, the header menu, the resize slider and the sort announcement.
  • Sortable headers carry aria-sort, and a polite live region says the new sort, like "Sorted by Amount, ascending". Each column divider is a slider named "Amount column width" whose value reads "180 pixels".
  • The table has aria-busy while isLoading or isFetchingNextPage; placeholder rows are hidden from screen readers. A virtualized table carries aria-rowcount and aria-rowindex, but find in page and printing see only the rendered rows.
  • In Lightning Blue, a link in a selected row is 4.17:1, below WCAG AA's 4.5:1. SLDS's own stylesheet does the same.

Responsive behavior

  • The table fills its container. Columns with a size keep it; the rest share what's left, at least their minSize each. When they don't fit, the table scrolls sideways inside its border.
  • After a resize, every column keeps its width and the table is as wide as its columns.
  • The row number column widens when the numbers reach three digits, so a number never clips.
  • Values clip with an ellipsis and a title unless the column sets wrapText. Decide per column: names clip, descriptions wrap with wrapTextMaxLines.
  • Without a height, the table grows with its rows. With one (className="h-96" or max-h-[60vh]), it scrolls inside itself under a header that stays in place.

Performance

  • Rows re-render only when their own data, the columns, the locale or their selection changes. Define columns outside the component, or in useMemo, or every row re-renders on every render.
  • Sorting runs in the browser unless you pass manualSorting. 10,000 rows sort in about 150ms to 320ms in development builds.
  • Without virtualize, every row renders. Use it from a few hundred rows: 10,000 rows keep about 30 in the page. Rendering all of 2,000 rows takes 1.5s to 5s in development builds.
  • A resize re-renders no rows: the width changes on the table's columns directly, and the page re-renders only as the drag starts and ends. With columnResizeMode: "onChange", columnSizing and the page update on every frame instead.

Rules

  • Call table.resetRowSelection() after a bulk delete removes selected rows. The table keeps the ids of rows that leave data, so a selection survives a refetch or a new page.
  • Give the table a height before passing virtualize or loadMoreOnScroll.
  • Select all covers the rows that are loaded, never every record on the server. A bulk action on the selection reaches only those.
  • A time column's values are times of day, like "09:30:00.000Z", and sort by the clock. An amount past 15 digits shows every digit when it's a decimal string, but two that differ only past the 15th may sort as equal.
  • A relative column ("3 hours ago") counts from now, an ISO instant. Pass it when the page renders on the server, or the current time is read at render and the server and the browser can differ.
  • An address value is { street, city, state, postalCode, country } and a name value is { salutation, firstName, middleName, lastName, suffix }. A cell shows either on one line ("312 Constitution Place, Austin, TX 78767, USA"), sorts by that text, and treats one with no parts as empty. A relative value is an instant and sorts by it; its date and time are in the cell's title and read after the text.
  • Set enableResizing: false on a column that must keep its size. To keep widths across visits, save them with onColumnSizingChange. Restore widths from the server through initialState.columnSizing; read widths from localStorage only after the page hydrates, and pass them through state.columnSizing.
  • Don't add rowNumber, select or actions columns; the table adds them, and a column of yours with one of those ids replaces it. Don't put buttons in header; use meta.headerActions.
  • Never pass asChild or render.