Tutorial: tu primer listado
El módulo de facturas completo — action, columnas, view y client en cinco archivos.
Vamos a construir el módulo facturas completo: tabla paginada en servidor con búsqueda, filtros, orden, URL compartible, doble clic al detalle y eliminación con refresco. Cinco archivos, todos completos — puedes copiarlos y renombrar.
Archivo 1 — Los tipos
export interface InvoiceRow {
id: string;
code: string;
clientName: string;
amount: number;
status: "PAID" | "PENDING" | "CANCELLED";
issuedAt: Date;
}La fila que viaja del servidor al cliente. Solo los campos que la tabla necesita — nunca el objeto entero de Prisma con relaciones (pesa en el payload RSC y expone datos de más).
Archivo 2 — La page action
"use server";
import type { DataTableFetchParams, DataTableFetchResult } from "@fesa/components/datatable";
import { parseRangeToken } from "@fesa/components/datatable";
import { currentUser } from "@/lib/user";
import { db } from "@/lib/db";
import type { InvoiceRow } from "../types/invoice.types";
interface InvoiceFilters {
status?: string[];
issued?: string[]; // token de rango: "2026-01-01..2026-03-31"
}
export async function getInvoicesPageAction(
params: DataTableFetchParams & InvoiceFilters
): Promise<DataTableFetchResult<InvoiceRow>> {
const user = await currentUser();
if (!user || user.role !== "ADMIN") {
throw new Error("Acceso denegado");
}
const range = parseRangeToken(params.issued?.[0]);
const where = {
...(params.globalFilter
? { OR: [
{ code: { contains: params.globalFilter } },
{ clientName: { contains: params.globalFilter } },
] }
: {}),
...(params.status?.length ? { status: { in: params.status } } : {}),
...(range.from || range.to
? { issuedAt: {
...(range.from ? { gte: new Date(range.from) } : {}),
...(range.to ? { lte: new Date(range.to) } : {}),
} }
: {}),
};
const sort = params.sorting[0];
const [rows, total] = await Promise.all([
db.invoice.findMany({
where,
orderBy: sort ? { [sort.id]: sort.desc ? "desc" : "asc" } : { issuedAt: "desc" },
skip: params.pageIndex * params.pageSize,
take: params.pageSize,
select: { id: true, code: true, clientName: true,
amount: true, status: true, issuedAt: true },
}),
db.invoice.count({ where }),
]);
return { data: rows, totalRows: total };
}
export async function deleteInvoiceAction(id: string) {
const user = await currentUser();
if (!user || user.role !== "ADMIN") return { error: "Acceso denegado" };
await db.invoice.delete({ where: { id } });
return { success: "Factura eliminada" };
}Fíjate en cuatro cosas:
- El permiso va dentro de la action. Un archivo
"use server"es un endpoint público: cualquier usuario autenticado puede invocarlo directo aunque la página tenga guard. - La page action lanza (
throw): la tabla mostrará ese mensaje exacto en un toast y, si quedó vacía, con botón Reintentar. Las mutaciones normales siguen el patrón{ error } / { success }de siempre. skip/takenunca reciben más de 100: la librería ya acotapageSizeconMAX_PAGE_SIZE.findManyycountenPromise.all— sin waterfall.
Archivo 3 — Las columnas
"use client";
import { memo } from "react";
import { MoreHorizontal, Trash2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu, DropdownMenuContent,
DropdownMenuItem, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { CustomColumnDef } from "@fesa/components/datatable";
import { formatCurrency, formatDateShort } from "@/lib/format";
import type { InvoiceRow } from "../types/invoice.types";
const STATUS_VARIANT = {
PAID: "default", PENDING: "secondary", CANCELLED: "destructive",
} as const;
const StatusCell = memo(({ status }: { status: InvoiceRow["status"] }) => (
<Badge variant={STATUS_VARIANT[status]}>{status}</Badge>
));
StatusCell.displayName = "StatusCell";
interface InvoiceActions {
onDelete: (row: InvoiceRow) => void;
}
const ActionsCell = memo(({ row, actions }: { row: InvoiceRow; actions: InvoiceActions }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => actions.onDelete(row)} className="text-destructive">
<Trash2 className="mr-2 h-4 w-4" /> Eliminar
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
));
ActionsCell.displayName = "ActionsCell";
export function createInvoiceColumns(
actions: InvoiceActions
): CustomColumnDef<InvoiceRow>[] {
return [
{ id: "code", header: "Código", accessorKey: "code",
cell: ({ row }) => <span className="font-mono">{row.code}</span> },
{ id: "clientName", header: "Cliente", accessorKey: "clientName",
cell: ({ row }) => row.clientName },
{ id: "amount", header: "Monto", accessorKey: "amount", align: "right",
cell: ({ row }) => formatCurrency(row.amount) },
{ id: "status", header: "Estado", accessorKey: "status",
cell: ({ row }) => <StatusCell status={row.status} /> },
{ id: "issuedAt", header: "Emitida", accessorKey: "issuedAt",
cell: ({ row }) => formatDateShort(row.issuedAt) },
{ id: "actions", header: "", enableSorting: false, enableHiding: false,
cell: ({ row }) => <ActionsCell row={row} actions={actions} /> },
];
}- Cada celda con contenido propio es un mini-componente en
memo()condisplayName— es lo que hace posible el "1 fila = 1 re-render". accessorKeyen todas las columnas de datos: sin ella, export, copiar e imprimir dejan la celda vacía.- La columna
actionsse fija sola a la derecha (por suid).
Archivo 4 — La view
import { Suspense } from "react";
import { parseTableSearchParams, readFilterList, readFilter,
type PageSearchParams } from "@fesa/components/datatable/server";
import { getInvoicesPageAction } from "../actions/invoices.actions";
import { InvoicesClient } from "./invoices.client";
import { InvoicesSkeleton } from "../components/invoices.skeleton";
export async function InvoicesView({ searchParams }: { searchParams?: PageSearchParams }) {
const params = (await searchParams) ?? {};
const tableParams = parseTableSearchParams(params, { defaultPageSize: 20 });
const status = readFilterList(params, "status");
const issued = readFilter(params, "issued");
const first = await getInvoicesPageAction({
...tableParams,
status,
issued: issued ? [issued] : undefined,
});
return (
<Suspense fallback={<InvoicesSkeleton />}>
<InvoicesClient
initialData={first.data}
initialTotalRows={first.totalRows}
initialPageIndex={tableParams.pageIndex}
initialPageSize={tableParams.pageSize}
initialSorting={tableParams.sorting}
initialFilter={tableParams.globalFilter}
/>
</Suspense>
);
}El servidor lee la misma URL que leerá el cliente, así que el initialData siempre corresponde a lo que la tabla espera: recargar la página 3 ordenada por monto pinta la página 3 ordenada por monto, sin parpadeos ni doble consulta.
Archivo 5 — El client
"use client";
import { memo, useCallback, useMemo, useRef, useState, useTransition } from "react";
import { toast } from "sonner";
import { SmartDataTable, useSmartTable,
type FilterDefinition } from "@fesa/components/datatable";
import { createInvoiceColumns } from "../components/invoices.columns";
import { deleteInvoiceAction, getInvoicesPageAction } from "../actions/invoices.actions";
import { DeleteInvoiceDialog } from "../components/delete-invoice-dialog";
import type { InvoiceRow } from "../types/invoice.types";
const FILTER_DEFINITIONS: FilterDefinition[] = [
{ key: "status", title: "Estado", options: [
{ label: "Pagada", value: "PAID" },
{ label: "Pendiente", value: "PENDING" },
{ label: "Cancelada", value: "CANCELLED" },
]},
{ key: "issued", title: "Fecha de emisión", type: "dateRange" },
];
const TABLE_STYLE = { striped: true, hover: true, stickyHeader: true, rounded: true } as const;
interface InvoicesClientProps {
initialData: InvoiceRow[];
initialTotalRows: number;
initialPageIndex: number;
initialPageSize: number;
initialSorting: { id: string; desc: boolean }[];
initialFilter: string;
}
export const InvoicesClient = memo(function InvoicesClient(props: InvoicesClientProps) {
const [pendingDelete, setPendingDelete] = useState<InvoiceRow | null>(null);
const [isPending, startTransition] = useTransition();
const table = useSmartTable<InvoiceRow>({
fetchPage: (params, filters) =>
getInvoicesPageAction({ ...params, status: filters.status, issued: filters.issued }),
filterDefinitions: FILTER_DEFINITIONS,
defaultPageSize: 20,
navigateTo: "/invoices",
});
const actionsRef = useRef({ onDelete: setPendingDelete });
actionsRef.current = { onDelete: setPendingDelete };
const columns = useMemo(() => createInvoiceColumns(actionsRef.current), []);
const confirmDelete = useCallback(() => {
if (!pendingDelete) return;
startTransition(async () => {
const result = await deleteInvoiceAction(pendingDelete.id);
if (result.error) {
toast.error(result.error);
return;
}
toast.success(result.success);
setPendingDelete(null);
void table.tableRef.current?.silentRefetch();
});
}, [pendingDelete, table.tableRef]);
const getRowId = useCallback((row: InvoiceRow) => row.id, []);
return (
<>
<SmartDataTable
{...table.smartProps}
initialData={props.initialData}
initialTotalRows={props.initialTotalRows}
columns={columns}
getRowId={getRowId}
pagination={{ pageSizeOptions: [10, 20, 50], showRowsInfo: true }}
filter={{ placeholder: "Buscar código o cliente…" }}
columnVisibility={{ enabled: true, alwaysVisibleColumns: ["code", "actions"] }}
toolbarConfig={{ customStart: table.filtersUI, showColumnVisibility: true }}
export={{ enabled: true, formats: ["csv", "xlsx"], filename: "facturas" }}
style={TABLE_STYLE}
emptyMessage="No hay facturas con estos filtros"
/>
<DeleteInvoiceDialog
invoice={pendingDelete}
isPending={isPending}
onClose={() => setPendingDelete(null)}
onConfirm={confirmDelete}
/>
</>
);
});Qué está pasando aquí:
useSmartTableconecta URL + filtros + fetch en una llamada.table.smartPropslleva elref, elfetchFn, losinitial*de la URL y el doble clic hacia/invoices/{id}.- Los
initialData/initialTotalRowsde la view se pasan aparte (vienen del servidor, no de la URL). - El patrón
actionsRefmantiene la identidad decolumnsestable aunque los handlers cambien: la tabla no se re-renderiza por gusto. - Tras eliminar:
silentRefetch()— la tabla trae la página fresca sin parpadear. Si la página tuviera KPIs server-rendered, añaderouter.refresh(). - Export CSV/XLSX sale gratis: sin
onExport, la librería genera el archivo con los headers de las columnas.
Con esto ya tienes
Paginación y orden en servidor · búsqueda con debounce · filtros de estado y rango de fechas ·
URL compartible (?page=2&status=PAID&issued=2026-01-01..2026-03-31) · export · errores del
servidor visibles con reintento · doble clic al detalle · columnas ocultables · toolbar completo.