Gestire 10 record è facile, gestirne 10.000 è ingegneria
Nelle prime settimane del mio tirocinio in IPS ho incontrato uno dei problemi più comuni, e più sottovalutati, del frontend enterprise: tabelle di dati con paginazione e filtri lato server. Con dieci record carichi tutto e filtri nel browser. Con diecimila non puoi: il server pagina, ordina e filtra, e il frontend deve chiedere ogni volta esattamente la fetta giusta quando qualcosa cambia.
Ho portato quell'esperienza direttamente nella mia tesi, ApexFlow. Clienti, ordini e prodotti sono tabelle PrimeNG in modalità lazy, e volevo lasciarmi alle spalle l'approccio in cui ogni cambiamento chiama a mano un metodo load().
Cosa deve restare coerente
Una tabella lato server ha più stato di quanto sembri: la pagina corrente, la dimensione della pagina, il campo e il verso dell'ordinamento, i filtri di ogni colonna, una ricerca libera. Se ne cambia uno qualsiasi la tabella deve fare una richiesta, con tutti, e ignorare la risposta di ogni richiesta superata. Sparso in un componente diventa un load() chiamato da sei punti diversi, ognuno dei quali può dimenticare un parametro.
Una classe per lo stato
TablePaginationState tiene ogni pezzo in un signal, e un solo computed li trasforma nei parametri che l'API si aspetta:
export class TablePaginationState {
currentPage = signal(1);
pageSize = signal(10);
sortField = signal<string | undefined>(undefined);
sortOrder = signal<'asc' | 'desc' | undefined>(undefined);
filters = signal<Record<string, string | boolean>>({});
totalRecords = signal(0);
params = computed((): HttpParamsConfig => ({
_page: this.currentPage(),
_limit: this.pageSize(),
...(this.sortField() ? { _sort: this.sortField(), _order: this.sortOrder() ?? 'asc' } : {}),
...this.filters(),
}));
}PrimeNG riporta ogni cambio di pagina, ordinamento e filtro di colonna in un unico TableLazyLoadEvent. La classe lo traduce e imposta un signal solo se il suo valore è cambiato davvero (i filtri si confrontano in profondità), così un clic che non cambia niente non fa nessuna richiesta.
Dallo stato alle richieste, senza subscribe
Il componente trasforma params in un observable, lascia a RxJS ciò che sa fare meglio e riporta il risultato in un signal per il template:
export class CustomerListComponent {
pagination = new TablePaginationState();
isLoading = signal(false);
customers = toSignal(
toObservable(this.pagination.params).pipe(
tap(() => this.isLoading.set(true)),
switchMap((params) => this.customerService.getCustomers(params)),
tap((response) => {
this.pagination.setTotal(response.total);
this.isLoading.set(false);
}),
map((response) => response.data),
),
{ initialValue: [] },
);
}toObservable emette quando cambia un parametro qualsiasi, una volta per cambiamento. switchMap annulla la richiesta ancora in corso quando ne parte una più nuova, così una pagina 2 lenta non sovrascrive mai la pagina 3. toSignal si iscrive per il componente e si disiscrive quando viene distrutto.
Il risultato
- Stato prevedibile. Ogni tabella ha gli stessi pochi signal, e i parametri di una richiesta sono una funzione di quei signal: niente può andare fuori sincrono.
- Componenti puliti. Un componente dichiara la sua tabella e cosa caricare. Non chiama mai
subscribe()néload(), quindi non ha sottoscrizioni da distruggere. - Un'integrazione realistica. Il frontend delega davvero paginazione e filtri al server. Mentre l'API Spring Boot prendeva forma, al suo posto c'era un JSON Server con un piccolo middleware personalizzato; il contratto sono solo i parametri, quindi cambiare backend tocca il servizio, non le tabelle.
Ogni tabella di ApexFlow riusa la stessa classe.