loading
logo

Me cansé de rehacer tablas de datos en Angular, así que liberé la mía

Descarga el CV
Angular

Me cansé de rehacer tablas de datos en Angular, así que liberé la mía

Todos los proyectos Angular en los que he trabajado estos últimos años han necesitado la misma pantalla. Una lista de registros que vienen de una API, paginada en el servidor porque hay cuarenta mil, ordenable al pulsar la cabecera de una columna, filtrable por columna, con un buscador y, casi siempre, con selección de filas porque alguien quiere archivar doce facturas de golpe.

Y cada vez, la construía otra vez.

No exactamente desde cero. Copiaba la anterior, arrancaba las partes específicas del proyecto viejo y descubría tres días después que la lógica de paginación tenía un bug sutil que ya había arreglado una vez, en un repositorio al que ya no tenía acceso.

Eso es lo que me agotó. No la dificultad — nada de esto es difícil — sino la repetición, y que el conocimiento se evaporaba.

Por qué no instalé algo y ya está

Miré.

La tabla de Angular Material te da buenas primitivas y espera que montes tú la pantalla: la tabla, el paginador y la cabecera ordenable son piezas separadas que conectas, y el resultado arrastra el lenguaje de diseño de Material. Si tu aplicación ya parece Material, eso es una virtud. La mía normalmente no, y pelearse con un sistema de diseño que no elegiste es trabajo aparte.

Las parrillas comerciales completas resuelven todo lo que he descrito y mucho más. También cuestan dinero por desarrollador, y para el tipo de herramienta interna que estaba construyendo — un back office para un cliente con cuatro usuarios — la licencia era difícil de justificar.

Así que seguí escribiéndola a mano, que es la opción que parece gratis y no lo es. La pagas en mantenimiento, repartido en años, a plazos.

La decisión que de verdad importaba

Cuando por fin me senté a construir esto bien, la pregunta de diseño no era cuáles funciones, sino de dónde vienen los datos.

Una tabla que es dueña de sus datos es fácil de escribir e inservible en cuanto el conjunto supera al navegador. Una tabla que no sabe nada de datos es honesta, pero te deja escribiendo el mismo código de paginación que intentabas evitar. Quería un componente que hiciera las dos cosas y que supiera en qué modo está sin un flag que la gente olvide poner.

La respuesta resultó ser simple: la tabla infiere el modo de lo que le das.

Dale un array pelado y no digas nada más, y pagina, ordena, filtra y busca en memoria. Dale un array y un total de elementos, y deja de tocar los datos: ahora solo pinta la página que le das y te avisa cuando el usuario quiere otra.

<!-- modo cliente: la tabla hace el trabajo -->
<hub-table [data]="orders" [headers]="headers" />

<!-- modo servidor: lo haces tú, la tabla pregunta -->
<hub-table
	[data]="orders()"
	[headers]="headers"
	[totalItems]="totalItems()"
	[(page)]="page"
	[(perPage)]="perPage"
	[(ordination)]="ordination"
	[(searchTerm)]="searchTerm"
	[loading]="isLoading()" />

Poner totalItems es el interruptor. No hay un booleano serverSide, porque un booleano es algo que puedes poner en el valor equivocado mientras los datos dicen lo contrario. Un total de elementos no puede contradecirse: si sabes cuántas filas hay en total, estás paginando en el servidor, o no lo sabrías.

Todo es un binding bidireccional

La segunda decisión sale de la primera, y es la que defendería con más fuerza.

La tabla no tiene outputs. Ni un EventEmitter. Todo lo que un consumidor querría escuchar es un model() — el binding bidireccional de señales de Angular — así que page, perPage, ordination, searchTerm, filters, loading y error se leen y se escriben desde los dos lados.

Esto importa más de lo que parece. Con un output recibes una notificación y luego la contabilidad es tuya: guardar el número de página en algún sitio, acordarte de volverlo a 1 cuando cambia el término de búsqueda, procurar que la idea de página actual del componente y la tuya no se separen. Con un model hay un solo valor, y ambos extremos miran a él.

readonly page = signal(1);
readonly perPage = signal(20);
readonly searchTerm = signal('');
readonly ordination = signal<PaginableTableOrdination | undefined>(undefined);

Enlaza esas cuatro señales y la tabla se convierte en una vista sobre tu estado en lugar de algo con lo que sincronizarte. Tu función de carga las lee; los clics del usuario las escriben. Nada en medio.

El orden es el ejemplo más claro. Pulsar una cabecera ordenable escribe { property: 'reference', direction: 'ASC' } en ordination y cambia a 'DESC' en el segundo clic. En modo cliente la tabla ordena en memoria. En modo servidor no hace nada más: tu efecto ve el valor nuevo y hace la petición. Mismo binding, misma forma, y el componente nunca necesitó saber cuál estaba haciendo.

(Una nota honesta: es un alternado de dos estados. No hay un tercer clic que vuelva a sin ordenar. Lo he querido dos veces y no lo he construido.)

Las señales hacen que todo se resuma a una línea

El resource() de Angular cambió otra vez la forma de este código, y la tabla ganó una segunda vía de entrada. Enlaza un resource entero:

protected readonly page = signal(1);

protected readonly invoices = resource({
	params: () => ({ page: this.page() }),
	loader: ({ params }) => this.api.fetchInvoices(params.page)
});
<hub-table [resource]="invoices" [headers]="headers" (pageChange)="page.set($event ?? 1)" />

La tabla lee tres cosas — value(), isLoading(), error() — y refleja las dos últimas en su propio estado, así que el esqueleto de carga y el panel de error salen gratis. La interfaz que pide es estructural, tres getters sin argumentos, lo que hace que resource() y httpResource() la satisfagan sin que la librería importe nada de un Angular más nuevo del que soporta.

Lo que merece la pena saber, porque me sorprendió al escribirlo: la tabla nunca llama a reload(). La paginación no vuelve a pedir por sí sola. La señal de página es un parámetro del resource, así que cambiarla reejecuta el loader por la maquinaria de Angular, no por un efecto lateral que la tabla dispare. Si quieres una recarga, cambias un parámetro. Es el único camino, y que solo haya uno es lo importante.

Lo que hice mal, y lo que sigue faltando

La librería es ng-hub-ui-paginable. MIT, sin cuenta, sin telemetría, y funciona desde Angular 18 en adelante. Es la parte del post en la que se supone que te digo que lo hace todo. No lo hace, y los huecos merecen decirse sin rodeos, porque una lista de funciones no te dice nada y una de límites te dice si puedes usarla.

Sin scroll virtual. Se pinta cada fila de la página actual. Para páginas de 10 a 100 filas, que es para lo que sirve la paginación, nunca ha sido el cuello de botella. Si necesitas pintar cincuenta mil filas en un contenedor con scroll, este es el componente equivocado y te mandaría a una parrilla hecha para eso.

Sin exportación, sin reordenar columnas, sin agrupar filas. Nunca los necesité lo bastante como para construirlos bien, y las funciones a medias son peores que las ausentes.

Las filas se siguen por índice. Así que una página recargada se repinta en vez de diferenciar por id. Sé por qué está mal y está en la lista.

La accesibilidad me costó una release, y escribir este post es lo que la provocó. Me senté a describir lo que la tabla le da a un lector de pantalla, fui a comprobarlo y vi que no anunciaba casi nada. aria-sort no aparecía en ningún punto del paquete, así que al usuario se le decía que las filas se habían reordenado y nunca por qué columna. El botón de ordenar no tenía nombre accesible. Tampoco las casillas, el control que abre una fila ni ningún filtro de columna. Una columna que solo tenía filtro seguía pintando un botón de ordenar enfocable que no hacía nada.

Eso está arreglado en la 22.25.0, junto con scope="col" en las cabeceras, aria-busy mientras la tabla carga, una región viva para el recuento de filas y aria-current en el paginador. Quedan dos cosas mal: los filtros por rango no tienen etiqueta, y los puntos suspensivos del paginador reciben foco cuando no deberían.

Lo que más acerté, por accidente, fue negarme a construir un framework de tablas. Es un componente con una entrada de datos y un juego de bindings bidireccionales. Cuando no hace lo que necesitas, proyectas una plantilla:

<hub-table [data]="invoices()" [headers]="headers">
	<ng-template cellTpt header="status" let-item="item">
		<span class="badge" [class.badge--paid]="item.status === 'paid'">
			{{ item.status }}
		</span>
	</ng-template>
</hub-table>

Esa escapatoria es la razón por la que aún no he tenido que forkearla en ningún proyecto.


Si tú también has estado rehaciendo la misma tabla: el código está en github.com/hub-env/ng-hub-ui-paginable, la documentación y los ejemplos en vivo están en hubui.dev/en/paginable/overview y se instala con npm i ng-hub-ui-paginable.

Y si construyes la tuya, yo tomaría igualmente la misma primera decisión. Deja que los datos le digan al componente en qué modo está. Todos los bugs que tuve en las versiones escritas a mano venían de un flag que decía una cosa mientras los datos decían otra.

EN