Saltar al contenido principal

Data Table

Tabla, filtro, selector de columnas, paginación y selección de filas en una sola pieza. Ordenar, filtrar y paginar corren en React plano sobre las filas que le pasás.

INV-1042
Acme Corpbilling@acmecorp.com
paid$250.00
INV-1043
Globexbilling@globex.com
pending$981.00
INV-1044
Initechbilling@initech.com
overdue$1,712.00
INV-1045
Umbrellabilling@umbrella.com
paid$2,443.00
INV-1046
Hoolibilling@hooli.com
pending$3,174.00
INV-1047
Vehement Capitalbilling@vehementcapital.com
overdue$3,905.00
0 of 23 selected
Page 1 of 4
1'use client';
2
3import { Badge } from '@/components/ui/badge';
4import { DataTable, type DataTableColumn } from '@/components/ui/data-table';
5
6type Invoice = {
7 id: string;
8 customer: string;
9 email: string;
10 amount: number;
11 status: 'paid' | 'pending' | 'overdue';
12 issued: Date;
13};
14
15const CUSTOMERS = [
16 'Acme Corp',
17 'Globex',
18 'Initech',
19 'Umbrella',
20 'Hooli',
21 'Vehement Capital',
22 'Stark Industries',
23 'Wayne Enterprises',
24 'Wonka',
25 'Cyberdyne',
26 'Tyrell',
27 'Soylent',
28 'Massive Dynamic',
29 'Aperture',
30 'Sirius Cybernetics',
31 'Oscorp',
32 'Gringotts',
33 'Prestige Worldwide',
34 'Dunder Mifflin',
35 'Los Pollos Hermanos',
36 'Pied Piper',
37 'Bluth Company',
38 'Sterling Cooper',
39];
40
41const STATUSES: Invoice['status'][] = ['paid', 'pending', 'overdue'];
42
43const INVOICES: Invoice[] = CUSTOMERS.map((customer, index) => ({
44 id: `INV-${String(1042 + index)}`,
45 customer,
46 email: `billing@${customer.toLowerCase().replace(/[^a-z]/g, '')}.com`,
47 amount: 250 + ((index * 731) % 4800),
48 status: STATUSES[(index * 7) % 3] ?? 'paid',
49 issued: new Date(2026, 8, 24 - index),
50}));
51
52const TONE: Record<Invoice['status'], 'success' | 'warning' | 'destructive'> = {
53 paid: 'success',
54 pending: 'warning',
55 overdue: 'destructive',
56};
57
58const money = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
59const date = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric' });
60
61const COLUMNS: DataTableColumn<Invoice>[] = [
62 {
63 id: 'id',
64 header: 'Invoice',
65 cell: (row) => <span className="font-mono">{row.id}</span>,
66 sortable: true,
67 sortValue: (row) => row.id,
68 filterValue: (row) => row.id,
69 width: '7rem',
70 },
71 {
72 id: 'customer',
73 header: 'Customer',
74 cell: (row) => (
75 <div className="flex flex-col">
76 <span className="text-foreground font-medium">{row.customer}</span>
77 <span className="text-muted-foreground">{row.email}</span>
78 </div>
79 ),
80 sortable: true,
81 sortValue: (row) => row.customer,
82 filterValue: (row) => `${row.customer} ${row.email}`,
83 },
84 {
85 id: 'status',
86 header: 'Status',
87 cell: (row) => <Badge variant={TONE[row.status]}>{row.status}</Badge>,
88 sortable: true,
89 sortValue: (row) => row.status,
90 filterValue: (row) => row.status,
91 },
92 {
93 id: 'issued',
94 header: 'Issued',
95 cell: (row) => date.format(row.issued),
96 sortable: true,
97 sortValue: (row) => row.issued,
98 hidden: true,
99 },
100 {
101 id: 'amount',
102 header: 'Amount',
103 cell: (row) => money.format(row.amount),
104 sortable: true,
105 sortValue: (row) => row.amount,
106 align: 'end',
107 width: '8rem',
108 },
109];
110
111export function Default() {
112 return (
113 <DataTable
114 data={INVOICES}
115 columns={COLUMNS}
116 getRowId={(row) => row.id}
117 pageSize={6}
118 selectable
119 filterPlaceholder="Filter invoices…"
120 emptyTitle="No invoices match"
121 emptyDescription="Try a customer name, an invoice number or a status."
122 />
123 );
124}

Instalación

pnpm dlx nachui add data-table

Anatomía

1import { DataTable, type DataTableColumn } from '@/components/ui/data-table';
1const columns: DataTableColumn<Invoice>[] = [
2 {
3 id: 'id',
4 header: 'Factura',
5 cell: (row) => row.id,
6 sortable: true,
7 sortValue: (row) => row.id,
8 },
9 {
10 id: 'customer',
11 header: 'Cliente',
12 cell: (row) => row.customer,
13 sortable: true,
14 sortValue: (row) => row.customer,
15 },
16 {
17 id: 'amount',
18 header: 'Monto',
19 cell: (row) => money(row.amount),
20 sortable: true,
21 sortValue: (row) => row.amount,
22 align: 'end',
23 },
24];
25
26<DataTable data={invoices} columns={columns} getRowId={(row) => row.id} pageSize={10} selectable />;
Sin children la tabla se arma sola: el filtro y el selector de columnas arriba, la tabla en el medio, el conteo de seleccionadas y el paginador abajo. Todo corre sobre el array que le pasás, así que no hay fetch, ni adaptador, ni librería debajo.
Pasale children para quedarte solo con las partes que querés. Todas leen el mismo contexto, así que una tabla sola sigue filtrando, ordenando y paginando con lo que conectes a useDataTable.
1<DataTable data={rows} columns={columns} getRowId={(row) => row.id}>
2 <DataTable.Content />
3</DataTable>

Composición

Un hybrid es composición visible. Abrí data-table.tsx y estos son los elements que lo forman, en este orden:
DataTable
├── DataTable.Toolbar
│ ├── Input → el filtro
│ └── DropdownMenu → visibilidad de columnas
├── DataTable.Content
│ └── Table
│ ├── Checkbox → seleccionar todo, seleccionar fila
│ └── Empty → sin filas
└── DataTable.Footer
└── Pagination

Forma de una columna

Una columna dice cómo leer la fila y cómo mostrarla. cell renderiza, sortValue y filterValue son lo que comparan el orden y el filtro. Si una columna no tiene filterValue, la tabla usa sortValue, y si tampoco, la celda cuando es texto plano.
CampoTipoDescripción
idstringClave estable, también el nombre en sort
headerReactNodeCelda de cabecera, y etiqueta en el menú de columnas
cell(row: T) => ReactNodeCelda del cuerpo
sortablebooleanConvierte la cabecera en un botón de orden
sortValue(row: T) => string | number | DateValor que se compara al ordenar
filterValue(row: T) => stringTexto que busca el filtro
align'start' | 'end'end alinea a la derecha y usa números tabulares
hiddenbooleanOculta al inicio, sigue disponible en el menú
widthstringCualquier ancho CSS para la cabecera

Orden

Hacé clic en una cabecera ordenable para ordenar ascendente, otra vez para descendente, y una tercera para limpiar. El orden es estable, así que las filas empatadas mantienen el orden en que llegaron. La cabecera lleva aria-sort para tecnología asistiva.

Selección

Con selectable aparece la columna de checkboxes. El de la cabecera selecciona la página actual, y queda indeterminado cuando solo hay parte seleccionada. La selección es un set de ids de getRowId, así que sobrevive al orden, al filtro y al paginado. Leela con onSelectedChange o controlala con selected.

Estado controlado

Filtro, orden y selección son no controlados por defecto. Pasá filter, sort o selected con sus handlers para manejarlos vos, para una URL, un store o una query al servidor. El helper puro applyDataTable es lo que corre el componente, así que podés correr la misma lógica en otro lado.
1const { rows, total, pageCount } = applyDataTable(data, {
2 columns,
3 filter: 'acme',
4 sort: { id: 'amount', direction: 'desc' },
5 page: 2,
6 pageSize: 10,
7});

Compacta

Quedate solo con la tabla cuando la barra y el pie serían ruido.
VersionDateChanges
1.4.02026-09-1812
1.3.22026-09-023
1.3.12026-08-271
1.3.02026-08-209
1.2.02026-07-3015
1'use client';
2
3import { DataTable, type DataTableColumn } from '@/components/ui/data-table';
4
5type Release = { version: string; date: string; changes: number };
6
7const RELEASES: Release[] = [
8 { version: '1.4.0', date: '2026-09-18', changes: 12 },
9 { version: '1.3.2', date: '2026-09-02', changes: 3 },
10 { version: '1.3.1', date: '2026-08-27', changes: 1 },
11 { version: '1.3.0', date: '2026-08-20', changes: 9 },
12 { version: '1.2.0', date: '2026-07-30', changes: 15 },
13];
14
15const COLUMNS: DataTableColumn<Release>[] = [
16 {
17 id: 'version',
18 header: 'Version',
19 cell: (row) => <span className="font-mono">{row.version}</span>,
20 },
21 { id: 'date', header: 'Date', cell: (row) => row.date },
22 { id: 'changes', header: 'Changes', cell: (row) => row.changes, align: 'end' },
23];
24
25export function Compact() {
26 return (
27 <div className="w-full max-w-md">
28 <DataTable data={RELEASES} columns={COLUMNS} getRowId={(row) => row.version}>
29 <DataTable.Content />
30 </DataTable>
31 </div>
32 );
33}

Referencia de API

DataTable

PropTipoPor defectoDescripción
dataT[]-Todas las filas, antes de filtrar y paginar
columnsDataTableColumn<T>[]-Definición de columnas
getRowId(row: T) => string-Id estable por fila, para keys y selección
pageSizenumber10Filas por página
selectablebooleanfalseAgrega la columna de checkboxes
selectedSet<string> | string[]-Selección controlada
onSelectedChange(selected: string[]) => void-Se llama cuando cambia la selección
sort{ id, direction } | null-Orden controlado
onSortChange(sort) => void-Se llama cuando cambia el orden
filterstring-Texto del filtro controlado
onFilterChange(filter: string) => void-Se llama cuando cambia el filtro
filterPlaceholderstring'Filter…'Placeholder del input de filtro
emptyTitlestring'No results'Título del estado vacío
emptyDescriptionstring-Descripción del estado vacío
labelsPartial<DataTableLabels>-Todos los textos visibles, con defaults en inglés
childrenReactNode-Layout propio, reemplaza las partes por defecto
classNamestring-Clases CSS adicionales

DataTable.Toolbar

PropTipoPor defectoDescripción
childrenReactNode-Acciones extra al lado del filtro
classNamestring-Clases CSS adicionales

DataTable.Content

PropTipoPor defectoDescripción
classNamestring-Clases CSS adicionales

DataTable.Footer

PropTipoPor defectoDescripción
childrenReactNode-Contenido extra al lado del conteo
classNamestring-Clases CSS adicionales

Helpers

FirmaDescripción
applyDataTable(data, { columns, filter, sort, page, pageSize })Devuelve { rows, total, pageCount }, la misma lógica que corre la tabla
useDataTable()El contexto de la tabla, para partes propias dentro de DataTable
¿Encontraste algo que mejorar?

¿Notaste un error, tipografía o detalle faltante en esta página? Ayúdanos a mejorar la documentación abriendo un issue en GitHub.

Crear un Issue