Tabla de datos
Construye un DataTable con TanStack Table, shadcn, ordenación, filtrado y paginación.
Receta
Tarjeta de referencia rápida — lista para copiar y pegar.
npx shadcn@latest add table button input
npm install @tanstack/react-table"use client";
import {
ColumnDef, flexRender, getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
}
export function DataTable<TData, TValue>({
columns, data,
}: DataTableProps<TData, TValue>) {
const table = useReactTable({
data, columns, getCoreRowModel: getCoreRowModel(),
});
return (
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((hg) => (
<TableRow key={hg.id}>
{hg.headers.map((h) => (
<TableHead key={h.id}>
{h.isPlaceholder ? null : flexRender(h.column.columnDef.header, h.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
Sin resultados.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
);
}Cuándo usarlo: Cuando necesites una tabla de datos con funciones avanzadas como ordenación, filtrado, paginación o selección de filas.
Ejemplo funcional
"use client";
import { useState } from "react";
import {
ColumnDef, flexRender, getCoreRowModel, getSortedRowModel,
getFilteredRowModel, getPaginationRowModel,
SortingState, ColumnFiltersState,
useReactTable,
} from "@tanstack/react-table";
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ArrowUpDown } from "lucide-react";
// Tipo de datos
type User = {
id: string;
name: string;
email: string;
role: "admin" | "editor" | "viewer";
status: "active" | "inactive";
lastLogin: string;
};
// Datos de ejemplo
const users: User[] = [
{ id: "1", name: "Alice Johnson", email: "alice@example.com", role: "admin", status: "active", lastLogin: "2025-12-01" },
{ id: "2", name: "Bob Smith", email: "bob@example.com", role: "editor", status: "active", lastLogin: "2025-11-28" },
{ id: "3", name: "Charlie Brown", email: "charlie@example.com", role: "viewer", status: "inactive", lastLogin: "2025-10-15" },
{ id: "4", name: "Diana Prince", email: "diana@example.com", role: "admin", status: "active", lastLogin: "2025-12-02" },
{ id: "5", name: "Eve Wilson", email: "eve@example.com", role: "editor", status: "inactive", lastLogin: "2025-09-20" },
{ id: "6", name: "Frank Castle", email: "frank@example.com", role: "viewer", status: "active", lastLogin: "2025-11-30" },
];
// Definiciones de columnas
const columns: ColumnDef<User>[] = [
{
accessorKey: "name",
header: ({ column }) => (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
Nombre <ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
},
{
accessorKey: "email",
header: "Correo electrónico",
},
{
accessorKey: "role",
header: "Rol",
cell: ({ row }) => (
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium capitalize">
{row.getValue("role")}
</span>
),
},
{
accessorKey: "status",
header: "Estado",
cell: ({ row }) => {
const status = row.getValue("status") as string;
return (
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${
status === "active" ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-600"
}`}>
{status}
</span>
);
},
},
{
accessorKey: "lastLogin",
header: ({ column }) => (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
Último acceso <ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
},
];
export function UsersTable() {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const table = useReactTable({
data: users,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
state: { sorting, columnFilters },
initialState: { pagination: { pageSize: 4 } },
});
return (
<div className="space-y-4">
{/* Filtro */}
<Input
placeholder="Filtrar por nombre..."
value={(table.getColumn("name")?.getFilterValue() as string) ?? ""}
onChange={(e) => table.getColumn("name")?.setFilterValue(e.target.value)}
className="max-w-sm"
/>
{/* Tabla */}
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((hg) => (
<TableRow key={hg.id}>
{hg.headers.map((h) => (
<TableHead key={h.id}>
{h.isPlaceholder ? null : flexRender(h.column.columnDef.header, h.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
Sin resultados.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* Paginación */}
<div className="flex items-center justify-between">
<p className="text-sm text-gray-500">
Página {table.getState().pagination.pageIndex + 1} de {table.getPageCount()}
</p>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
Anterior
</Button>
<Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
Siguiente
</Button>
</div>
</div>
</div>
);
}Qué demuestra esto:
- TanStack Table con componentes Table de shadcn
- Ordenación de columnas con botón de alternancia
- Filtrado de texto en la columna de nombre
- Paginación del lado del cliente con controles de página
- Renderizadores de celdas personalizados (badges para rol y estado)
- Manejo del estado vacío
Profundización
Cómo funciona
useReactTablees el hook principal — recibe data, columns y modelos de funcionalidad- Los modelos de funcionalidad (
getCoreRowModel,getSortedRowModel, etc.) son plugins con tree-shaking - Las definiciones de columnas especifican cómo acceder a los datos (
accessorKey), renderizar encabezados y renderizar celdas flexRenderrenderiza una definición de columna (que puede ser un string, un componente o una función)- El state (sorting, filtros, paginación) se gestiona externamente mediante state de React y se pasa a la tabla
- Los componentes
Tablede shadcn son wrappers ligeros alrededor de<table>,<thead>,<tr>,<td>con estilos consistentes
Variaciones
Selección de filas con checkboxes:
import { Checkbox } from "@/components/ui/checkbox";
const selectColumn: ColumnDef<User> = {
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(v) => row.toggleSelected(!!v)}
/>
),
};
// Añadir al array de columnas
const columns = [selectColumn, ...otherColumns];
// Habilitar selección de filas en useReactTable
const table = useReactTable({
...config,
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
state: { rowSelection },
});Paginación del lado del servidor:
const table = useReactTable({
data: serverData.rows,
columns,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
pageCount: serverData.totalPages,
onPaginationChange: (updater) => {
const next = typeof updater === "function"
? updater(pagination)
: updater;
setPagination(next);
fetchPage(next.pageIndex, next.pageSize);
},
state: { pagination },
});Menú desplegable de acciones por fila:
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { MoreHorizontal } from "lucide-react";
const actionsColumn: ColumnDef<User> = {
id: "actions",
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => editUser(row.original)}>Editar</DropdownMenuItem>
<DropdownMenuItem className="text-red-600" onClick={() => deleteUser(row.original.id)}>
Eliminar
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
};Notas de TypeScript
// ColumnDef es genérico
const columns: ColumnDef<User>[] = [
{ accessorKey: "name", header: "Nombre" }, // accessorKey está tipado contra User
];
// row.original está completamente tipado
cell: ({ row }) => {
const user: User = row.original;
return <span>{user.email}</span>;
};
// row.getValue está tipado por columna
row.getValue("status"); // tipado por accessorKey
// Componente DataTable genérico
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
}Errores comunes
-
Las definiciones de columnas deben ser estables — Definir columnas dentro del componente provoca re-renderizados infinitos. Solución: Define las columnas fuera del componente o envuélvelas en
useMemo. -
accessorKeyvsaccessorFn—accessorKeyes para acceso simple a propiedades;accessorFnes para valores calculados o anidados. Confundirlos provoca problemas de tipos. -
La paginación del servidor se reinicia al filtrar — Cuando cambian los filtros, debes reiniciar a la página 0. Solución: Reinicia
pageIndexcuando cambien los filtros. -
Conjuntos de datos grandes — La ordenación/filtrado del lado del cliente con más de 10.000 filas puede causar lag. Solución: Usa paginación, ordenación y filtrado del lado del servidor con opciones
manual*. -
Encabezados fijos — La Table de shadcn no incluye encabezados fijos por defecto. Solución: Añade
className="sticky top-0 bg-white z-10"aTableHeader.
Alternativas
| Alternativa | Úsala cuando | No la uses cuando |
|---|---|---|
<table> simple | Muestras datos pequeños y estáticos sin interactividad | Necesitas ordenación, filtrado o paginación |
| AG Grid | Necesitas funciones tipo Excel (edición de celdas, pivot, gráficos) | Una tabla más simple es suficiente |
| React Virtuoso | Tienes miles de filas y necesitas virtualización | Los datos paginados encajan en tu UX |
| Tremor Table | Usas la librería de dashboard Tremor | Usas shadcn/ui |
FAQs
¿Cuáles son los paquetes principales necesarios para construir un DataTable con shadcn?
@tanstack/react-tablepara la lógica de tabla (ordenación, filtrado, paginación)- Componentes shadcn
table,buttoneinputpara la UI - Instálalos con
npx shadcn@latest add table button inputynpm install @tanstack/react-table
¿Qué es flexRender y por qué es necesario?
flexRenderrenderiza una definición de columna que puede ser un string, un componente de React o una función- Conecta la configuración de columnas de TanStack Table con la salida JSX real
- Se usa tanto para el renderizado de encabezados como de celdas
¿Cómo añades ordenación a una columna?
header: ({ column }) => (
<Button variant="ghost" onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}>
Nombre <ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),También añade getSortedRowModel: getSortedRowModel() y onSortingChange: setSorting a useReactTable.
Trampa: ¿Por qué las definiciones de columnas definidas dentro del componente provocan re-renderizados infinitos?
- React crea una nueva referencia de array en cada renderizado
- TanStack Table detecta la nueva referencia y vuelve a procesar las columnas, provocando otro renderizado
- Solución: define las columnas fuera del componente o envuélvelas en
useMemo
¿Cómo implementas paginación del lado del servidor en lugar de del lado del cliente?
- Establece
manualPagination: trueypageCount: serverData.totalPages - Usa
onPaginationChangepara obtener la siguiente página del servidor - No incluyas
getPaginationRowModel()ya que el servidor se encarga del particionado
¿Cómo añades selección de filas con checkboxes?
const selectColumn: ColumnDef<User> = {
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(v) => row.toggleSelected(!!v)}
/>
),
};¿Cuál es la diferencia entre accessorKey y accessorFn?
accessorKeyes para acceso directo y simple a propiedades (p. ej.,"name"se mapea arow.name)accessorFnes para valores calculados o anidados (p. ej.,(row) => row.address.city)- Confundirlos provoca problemas de tipos en TypeScript
¿Cómo se tipa row.original en TanStack Table?
row.originalestá completamente tipado como tu tipo de datos (p. ej.,User)- El genérico
ColumnDef<User>se propaga de modo querow.original.emailes type-safe
Trampa: ¿Por qué el filtrado falla cuando cambias de página pero no reinicias a la página 0?
- La paginación del lado del servidor no reinicia automáticamente
pageIndexcuando cambian los filtros - Puedes terminar solicitando una página que ya no existe
- Solución: reinicia
pageIndexa 0 siempre que cambien los filtros
¿Cómo añades un menú desplegable de acciones por fila?
const actionsColumn: ColumnDef<User> = {
id: "actions",
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => editUser(row.original)}>Editar</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
};¿Cómo tipas un componente DataTable genérico reutilizable?
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
}
export function DataTable<TData, TValue>({
columns, data
}: DataTableProps<TData, TValue>) { ... }¿Cómo haces que los encabezados de tabla sean fijos?
- La Table de shadcn no incluye encabezados fijos por defecto
- Añade
className="sticky top-0 bg-white z-10"aTableHeader - Asegúrate de que el contenedor de la tabla tenga una altura fija con
overflow-y: auto