Skip to content

Integración con TanStack Table ​

TanStack Table (anteriormente React Table v8) es una librería de tablas headless para construir potentes cuadrículas de datos. QuickModel proporciona el modelo de filas tipado, la coerción en el momento de la carga de datos y getFormSchema() para auto-generar definiciones de columnas ordenables y filtrables.

Patrones clave ​

Caso de usoSolución QuickModel
Definición del tipo de filaInstancia QModel como genérico TData
Coerción masiva de datoscreateMany() sobre la respuesta raw de la API
Metadatos de columna desde decoradoresMétodo estático getFormSchema() para construir column defs
Formato de celdaCampos @QComputed disponibles en cada instancia de fila
Edición — actualización inmutable de filarow.$qCopy(patch) reemplaza la fila sin mutación
Seguimiento de cambios por filaoriginal.$qIsDirty(edited) para detectar edición inline

Configuración Básica ​

typescript
// models/product.model.ts
import { QModel, Quick, QField, QRule, QComputed } from 'quickmodel';

interface IProduct {
	id: string;
	name: string;
	price: number;
	stock: number;
	category: string;
	createdAt: Date;
}

@Quick(
	{
		id: 'string',
		name: 'string',
		price: 'number',
		stock: 'number',
		category: 'string',
		createdAt: Date,
	},
	{ unknownPropertyPolicy: 'strip' }
)
export class ProductModel extends QModel<IProduct> {
	declare id: string;

	@QField({ label: 'Nombre', required: true })
	declare name: string;

	@QField({ label: 'Precio (€)' })
	declare price: number;

	@QField({ label: 'Stock' })
	@QRule((v: number) => v >= 0, 'El stock no puede ser negativo')
	declare stock: number;

	@QField({ label: 'Categoría' })
	declare category: string;

	declare createdAt: Date;

	@QComputed()
	get formattedPrice(): string {
		return `€${this.price.toFixed(2)}`;
	}

	@QComputed()
	get isLowStock(): boolean {
		return this.stock < 5;
	}
}

React — Tabla Básica ​

tsx
// components/ProductTable.tsx
import {
	useReactTable,
	getCoreRowModel,
	getSortedRowModel,
	flexRender,
	type ColumnDef,
} from '@tanstack/react-table';
import { ProductModel } from '../models/product.model';

const columns: ColumnDef<ProductModel>[] = [
	{ accessorKey: 'name', header: 'Nombre' },
	{
		accessorKey: 'formattedPrice',
		header: 'Precio',
		// @QComputed — no se necesita formatear en la celda
	},
	{
		accessorKey: 'stock',
		header: 'Stock',
		cell: ({ row }) => (
			<span
				style={{ color: row.original.isLowStock ? 'red' : 'inherit' }}>
				{row.original.stock}
			</span>
		),
	},
	{ accessorKey: 'category', header: 'Categoría' },
];

export function ProductTable({ rawData }: { rawData: unknown[] }) {
	// createMany() coerciona fechas, elimina campos desconocidos
	const { instances: data, errors } = ProductModel.createMany(rawData);

	const table = useReactTable({
		data,
		columns,
		getCoreRowModel: getCoreRowModel(),
		getSortedRowModel: getSortedRowModel(),
	});

	return (
		<table>
			<thead>
				{table.getHeaderGroups().map((hg) => (
					<tr key={hg.id}>
						{hg.headers.map((h) => (
							<th
								key={h.id}
								onClick={h.column.getToggleSortingHandler()}
								style={{ cursor: 'pointer' }}>
								{flexRender(
									h.column.columnDef.header,
									h.getContext()
								)}
								{h.column.getIsSorted() === 'asc'
									? ' ↑'
									: h.column.getIsSorted() === 'desc'
										? ' ↓'
										: ''}
							</th>
						))}
					</tr>
				))}
			</thead>
			<tbody>
				{table.getRowModel().rows.map((row) => (
					<tr key={row.id}>
						{row.getVisibleCells().map((cell) => (
							<td key={cell.id}>
								{flexRender(
									cell.column.columnDef.cell,
									cell.getContext()
								)}
							</td>
						))}
					</tr>
				))}
			</tbody>
		</table>
	);
}

Auto-Generar Columnas desde getFormSchema() ​

Evita escribir definiciones de columnas a mano — derívala desde los metadatos de @QField:

typescript
import type { ColumnDef } from '@tanstack/react-table';
import { ProductModel } from '../models/product.model';

function buildColumnsFromSchema<T extends object>(ModelClass: {
	getFormSchema: () => Array<{ field: string; label: string }>;
}): ColumnDef<T>[] {
	return ModelClass.getFormSchema().map(({ field, label }) => ({
		accessorKey: field as keyof T extends string ? keyof T : string,
		header: label,
	}));
}

// Genera column defs desde los decoradores @QField de ProductModel
const autoColumns = buildColumnsFromSchema<ProductModel>(ProductModel);

Edición Inline con $qCopy() ​

tsx
import { useState } from 'react';
import { ProductModel } from '../models/product.model';

function EditableRow({
	product,
	onSave,
}: {
	product: ProductModel;
	onSave: (p: ProductModel) => void;
}) {
	const [editing, setEditing] = useState(false);
	const [draft, setDraft] = useState(product);

	const isDirty = product.$qIsDirty(draft);

	return (
		<tr>
			<td>
				{editing ? (
					<input
						value={draft.name}
						onChange={(e) =>
							setDraft(
								(prev) =>
									prev.$qCopy({
										name: e.target.value,
									}) as ProductModel
							)
						}
					/>
				) : (
					product.name
				)}
			</td>
			<td>
				<button onClick={() => setEditing((v) => !v)}>
					{editing ? 'Cancelar' : 'Editar'}
				</button>
				{isDirty && (
					<button
						onClick={() => {
							const { valid } = draft.$qCheckRules();
							if (valid) {
								onSave(draft);
								setEditing(false);
							}
						}}>
						Guardar
					</button>
				)}
			</td>
		</tr>
	);
}

Filtrado con Campos QModel ​

tsx
import {
	useReactTable,
	getCoreRowModel,
	getFilteredRowModel,
} from '@tanstack/react-table';
import { useState } from 'react';

function FilterableTable({ data }: { data: ProductModel[] }) {
	const [globalFilter, setGlobalFilter] = useState('');

	const table = useReactTable({
		data,
		columns,
		state: { globalFilter },
		onGlobalFilterChange: setGlobalFilter,
		getCoreRowModel: getCoreRowModel(),
		getFilteredRowModel: getFilteredRowModel(),
		globalFilterFn: (row, _colId, filterValue: string) => {
			// Filtra por nombre, categoría y formattedPrice (@QComputed)
			const search = filterValue.toLowerCase();
			const prod = row.original;
			return (
				prod.name.toLowerCase().includes(search) ||
				prod.category.toLowerCase().includes(search) ||
				prod.formattedPrice.includes(search)
			);
		},
	});

	return (
		<div>
			<input
				placeholder="Buscar productos..."
				value={globalFilter}
				onChange={(e) => setGlobalFilter(e.target.value)}
			/>
			{/* JSX de la tabla */}
		</div>
	);
}

Paginación en Servidor con TanStack Query ​

Combina TanStack Table (estado de paginación) con TanStack Query (obtención de datos) y QuickModel (coerción):

tsx
import { useQuery } from '@tanstack/react-query';
import { useReactTable, getPaginationRowModel } from '@tanstack/react-table';
import { useState } from 'react';
import { ProductModel } from '../models/product.model';

interface IPage {
	items: unknown[];
	total: number;
}

function ServerPaginatedTable() {
	const [pagination, setPagination] = useState({
		pageIndex: 0,
		pageSize: 20,
	});

	const { data } = useQuery({
		queryKey: ['products', pagination],
		queryFn: async (): Promise<{
			rows: ProductModel[];
			rowCount: number;
		}> => {
			const res: IPage = await fetch(
				`/api/products?page=${pagination.pageIndex}&size=${pagination.pageSize}`
			).then((r) => r.json());

			const { instances } = ProductModel.createMany(res.items);
			return { rows: instances, rowCount: res.total };
		},
	});

	const table = useReactTable({
		data: data?.rows ?? [],
		columns,
		rowCount: data?.rowCount ?? 0,
		state: { pagination },
		onPaginationChange: setPagination,
		manualPagination: true,
		getCoreRowModel: getCoreRowModel(),
		getPaginationRowModel: getPaginationRowModel(),
	});

	return (
		<div>
			{/* JSX de la tabla */}
			<button
				onClick={() => table.previousPage()}
				disabled={!table.getCanPreviousPage()}>
				Anterior
			</button>
			<button
				onClick={() => table.nextPage()}
				disabled={!table.getCanNextPage()}>
				Siguiente
			</button>
		</div>
	);
}

Columna de Informe de Validación ​

Muestra el estado de validación por fila usando $qValidationReport():

tsx
const validationColumn: ColumnDef<ProductModel> = {
	id: 'status',
	header: 'Estado',
	cell: ({ row }) => {
		const report = row.original.$qValidationReport();
		if (report.valid) return <span style={{ color: 'green' }}>✓</span>;
		return (
			<span
				title={report.errors.map((e) => e.message).join(', ')}
				style={{ color: 'red' }}>
				⚠ {report.errors.length} error
				{report.errors.length > 1 ? 'es' : ''}
			</span>
		);
	},
};

Ver también ​