Skip to content
Data & ChartsPro

Data Table

A data table on TanStack Table: sort on several columns, filter from the column definitions, select rows with a floating action bar, expand, pin, page and export to CSV — with controlled state for server-side data. The free Data Grid stays the small one.

Usage

36 rows
Alina LorenzPaidFree$492026-01-01
Tilo BrandtOverdueCore$1862026-02-06
Lena VossPaidPro$3232026-03-11
Jonas KellerRefundedFree$4602026-04-16
Ida HaasPendingCore$5972026-05-21
0 of 36 rows selected
Rows per page

Installation

Data Table comes with WunderUI Core and Pro, in the package from your plan. See the plans.

Examples

Keyboard

The table is a grid with one tab stop. Arrow keys move between cells, Home and End jump along the row, Ctrl+Home and Ctrl+End to the first and last cell, Page Up and Page Down ten rows. Enter or Space does a cell's main thing (sort a column, tick a checkbox), Shift+Enter adds a column to the sort, F2 moves into the controls of a cell and Escape comes back. Tab leaves the table.

36 rows
Alina LorenzPaidFree$492026-01-01
Tilo BrandtOverdueCore$1862026-02-06
Lena VossPaidPro$3232026-03-11
Jonas KellerRefundedFree$4602026-04-16
Ida HaasPendingCore$5972026-05-21
0 of 36 rows selected
Rows per page

Multi-sort

Click a header to sort by it (ascending, descending, off). Shift-click, or Shift+Enter, or “Then by” in the column menu adds a column to the sort; a number shows the order. aria-sort is set on every sortable header.

36 rows
Tilo VossOverdueCore$9302026-05-10
Svenja LorenzOverdueFree$8712026-07-03
Nora HaasOverduePro$8332026-06-21
Tilo KellerOverdueCore$7742026-08-14
Svenja BrandtOverdueFree$7152026-01-07
36 rows
Rows per page

Filters from the column definitions

A column with meta.filter is filterable: the Filter button opens a command palette of those columns, each filter becomes a chip you can edit or remove. Types: text, select, multiSelect and number (a range). Select options are read from the rows unless you list them.

14 rows, filtered
Lena VossPaidPro$3232026-03-11
Ida HaasPendingCore$5972026-05-21
Paul KellerPendingFree$3822026-01-18
Ole KrusePendingPro$6562026-03-28
Alina BrandtPaidFree$7932026-04-05
14 rows
Rows per page

Selection and the action bar

Row checkboxes select, Shift-click selects a range, the header checkbox selects the page. While rows are selected a floating bar shows the count, your bulkActions, Select all, Export selected and Clear.

12 rows
Alina LorenzPaidFree$492026-01-01
Tilo BrandtOverdueCore$1862026-02-06
Lena VossPaidPro$3232026-03-11
Jonas KellerRefundedFree$4602026-04-16
Ida HaasPendingCore$5972026-05-21
Felix KruseRefundedPro$7342026-06-26
2 of 12 rows selected
Rows per page

Select rows, then use the bar at the bottom. Shift-click selects a range.

Expandable rows

Pass renderSubRow and each row gets a chevron that opens a panel under it. getRowCanExpand turns it off for single rows (refunded orders have no details here).

36 rows
Expand row
Alina LorenzPaidFree$492026-01-01
Order
ORD-1001
Company
Acme Labs
Seats
1
Issued
2026-01-01
Tilo BrandtOverdueCore$1862026-02-06
Lena VossPaidPro$3232026-03-11
Jonas KellerRefundedFree$4602026-04-16
Ida HaasPendingCore$5972026-05-21
36 rows
Rows per page

Column pinning

Pin a column to the start or the end from its menu, or set columnPinning. Pinned columns stay put while the rest scrolls sideways. The checkbox and expander columns always stay first.

36 rows
Alina LorenzAcme LabsPaidFree2026-01-011ORD-1001$49
Tilo BrandtAcme CloudOverdueCore2026-02-064ORD-1002$186
Lena VossWunderUI StudioPaidPro2026-03-117ORD-1003$323
Jonas KellerAcme RetailRefundedFree2026-04-1610ORD-1004$460
Ida HaasAcme HealthPendingCore2026-05-2113ORD-1005$597
0 of 36 rows selected
Rows per page

Server-side data

serverSide hands paging, sorting and filtering to your API. onQueryChange fires with { pagination, sorting, columnFilters, globalFilter } whenever one changes; you answer with data (one page, already in order) and rowCount. Here the “API” waits 650 ms; make the next request fail to see the error state and its retry.

Loading rows
0 of 0 rows selected
Rows per page
Sorting, filtering and paging each ask the “server” again.

Controlled state

Every part of the state is controlled once you pass its value and its onChange: sorting, columnFilters, globalFilter, columnVisibility, rowSelection, pagination, columnPinning and expanded. Parts you leave out keep working on their own, seeded by initialState.

36 rows
Tilo VossOverdueCore$9302026-05-10
Lena HaasPaidPro$9112026-09-19
Svenja LorenzOverdueFree$8712026-07-03
Ben VossPaidCore$8522026-02-12
Nora HaasOverduePro$8332026-06-21
0 of 36 rows selected
Rows per page
{
  "sorting": [
    {
      "id": "amount",
      "desc": true
    }
  ],
  "selected": [],
  "hidden": [
    "company"
  ]
}

State in the URL

useDataTableUrlState keeps page, sort, filters and search in the address bar with history.replaceState: the table starts from the URL it finds, so a view can be linked and reloaded. Values equal to the defaults are left out, other parameters stay, and prefix separates several tables on one page. parseDataTableQuery and serializeDataTableQuery are the two halves if you want to do the routing yourself (the preview here only shows the query string it would write).

36 rows
Alina LorenzPaidFree$492026-01-01
Tilo BrandtOverdueCore$1862026-02-06
Lena VossPaidPro$3232026-03-11
Jonas KellerRefundedFree$4602026-04-16
Ida HaasPendingCore$5972026-05-21
36 rows
Rows per page
Sort, filter, search or turn a page: the query string appears here. An untouched table has none.

CSV export

Export writes the visible columns of every row after filtering and sorting; with a selection, the action bar exports just those. Cells that start with = + - or @ get a leading apostrophe so spreadsheets do not run them. onExport replaces the download, for exports your server makes.

8 rows
Alina LorenzPaidFree$492026-01-01
Tilo BrandtOverdueCore$1862026-02-06
Lena VossPaidPro$3232026-03-11
Jonas KellerRefundedFree$4602026-04-16
0 of 8 rows selected
Rows per page
Press Export. Without onExport the table saves acme-orders.csv; here the CSV shows up below instead.

Loading

loading shows skeleton rows in the real column widths and sets aria-busy on the grid. Toolbar and pager stay in place.

Loading rows
36 rows
Rows per page

Empty

Without data the table says so; give emptyState your own message and call to action. When a search or filter caused the empty result, the table offers Reset filters instead.

0 rows

No orders yet

Orders appear here after the first checkout.

0 rows
Rows per page

Error

error replaces the rows with a message (true, a string or { title, description }); onRetry adds the Try again button. The message is announced as an alert.

The data could not be loaded

Orders are not available

The orders service did not answer. Try again in a moment.

0 rows
Rows per page

In context

How the component is used inside real screens — the same compositions as the Usage section of the Figma page.

Invoices in a card

In a settings or billing page the table sits inside a Card; className drops its own frame.

Invoices
Every invoice for Acme Labs, newest first.
8 rows
Ole BrandtPendingPro$3442026-09-08
Tilo KellerOverdueCore$7742026-08-14
Jonas HaasRefundedFree$3042026-07-20
Felix KruseRefundedPro$7342026-06-26
Svenja VossOverdueFree$5592026-04-11
8 rows
Rows per page

API Reference

PropTypeDefault
columnsDataTableColumnDef<T>[]—
dataT[]—
getRowId(row, index) => stringrow index
selectable / searchable / pinnable / exportablebooleantrue
columnVisibilityMenubooleantrue
paginated / pageSizeOptionsboolean / number[]true / [10, 20, 50]
renderSubRow / getRowCanExpand(row) => ReactNode / (row) => boolean— (no expander)
bulkActions({ rows, ids, clear }) => ReactNode—
toolbarActionsReactNode—
rowLabel(row) => string"row N"
loading / loadingRowsboolean / numberfalse / page size, at most 8
error / onRetryboolean | string | { title, description } / () => void—
emptyStateReactNodebuilt-in
sorting / onSortingChangeSortingStateuncontrolled
columnFilters / onColumnFiltersChangeColumnFiltersStateuncontrolled
globalFilter / onGlobalFilterChangestringuncontrolled
columnVisibility / onColumnVisibilityChangeRecord<string, boolean>uncontrolled
rowSelection / onRowSelectionChangeRecord<string, true>uncontrolled
pagination / onPaginationChange{ pageIndex, pageSize }{ 0, 10 }
columnPinning / onColumnPinningChange{ start: string[], end: string[] }uncontrolled
expanded / onExpandedChangeRecord<string, boolean>uncontrolled
initialStatePartial<DataTableState>—
onQueryChange(query) => void— (not called on mount)
serverSideboolean | { pagination?, sorting?, filtering? }false
rowCountnumberdata.length
useDataTableUrlState({ prefix, defaults, search }){ initialState, onQueryChange }—
exportFileName / onExportstring / ({ scope, rows, csv, fileName }) => void"table" / file download
column meta{ label?, filter?, numeric?, exportValue?, cellClassName? }—