Tabla de datos
Construye un DataTable con TanStack Table, shadcn, ordenación, filtrado y paginación.
Busca en todas las páginas de la documentación
Construye un DataTable con TanStack Table, shadcn, ordenación, filtrado y paginación.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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.
"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:
useReactTable es el hook principal - recibe data, columns y modelos de funcionalidadgetCoreRowModel, getSortedRowModel, etc.) son plugins con tree-shakingaccessorKey), renderizar encabezados y renderizar celdasflexRender renderiza una definición de columna (que puede ser un string, un componente o una función)Table de shadcn son wrappers ligeros alrededor de <table>, <thead>, <tr>, <td> con estilos consistentesSelecció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>
),
};// 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[];
}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.
accessorKey vs accessorFn - accessorKey es para acceso simple a propiedades; accessorFn es 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 pageIndex cuando 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" a TableHeader.
| 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 |
@tanstack/react-table para la lógica de tabla (ordenación, filtrado, paginación)table, button e input para la UInpx shadcn@latest add table button input y npm install @tanstack/react-tableflexRender renderiza una definición de columna que puede ser un string, un componente de React o una funciónheader: ({ 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.
useMemomanualPagination: true y pageCount: serverData.totalPagesonPaginationChange para obtener la siguiente página del servidorgetPaginationRowModel() ya que el servidor se encarga del particionadoconst 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)}
/>
),
};accessorKey es para acceso directo y simple a propiedades (p. ej., "name" se mapea a row.name)accessorFn es para valores calculados o anidados (p. ej., (row) => row.address.city)row.original está completamente tipado como tu tipo de datos (p. ej., User)ColumnDef<User> se propaga de modo que row.original.email es type-safepageIndex cuando cambian los filtrospageIndex a 0 siempre que cambien los filtrosconst 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>
),
};interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
}
export function DataTable<TData, TValue>({
columns, data
}: DataTableProps<TData, TValue>) { ... }className="sticky top-0 bg-white z-10" a TableHeaderoverflow-y: autoRevisado por Chris St. John·Última actualización: 16 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥