Maîtrisez p-table de PrimeNG : tri multi-colonnes, filtres globaux, pagination côté client et serveur, lazy load, virtual scroll et export CSV en Angular 17+.
Pourquoi p-table ?
<p-table> est le composant phare de PrimeNG pour Angular. Il combine en un seul tag les fonctionnalités habituellement dispersées dans plusieurs librairies : tri multi-colonnes, filtrage par colonne et global, pagination client/serveur, sélection (single/multi/checkbox), édition inline, virtual scroll, export, drag & drop des colonnes, redimensionnement et persistance d'état.
Avec Angular 17+ et PrimeNG 17+, le composant fonctionne en mode standalone : un simple imports: [TableModule] dans votre composant suffit, sans NgModule intermédiaire.
Comparatif rapide des options
| Besoin | Propriété p-table | Quand l'activer |
|---|---|---|
| Tri sur 1 colonne | sortMode="single" (défaut) | Cas standard |
| Tri multi-colonnes | sortMode="multiple" | Tableaux analytiques |
| Pagination client | [paginator]="true" | < 1000 lignes en mémoire |
| Pagination serveur | [lazy]="true" | > 1000 lignes / API paginée |
| 10k+ lignes fluides | [virtualScroll]="true" | Logs, séries temporelles |
| Filtre global | [globalFilterFields] | Recherche transverse |
pSortableColumn, pTemplate et le module TableModule sont compatibles standalone. Plus besoin de BrowserAnimationsModule au niveau racine — utilisez provideAnimations() dans app.config.ts.
Installation et imports standalone
Installer PrimeNG et les dépendances
# PrimeNG core + icônes + thème par défaut
npm install primeng primeicons
# Animations Angular (requis pour les transitions p-table)
npm install @angular/animations
Configurer les animations dans app.config.ts
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideAnimations } from '@angular/platform-browser/animations';
import { provideHttpClient } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [
provideAnimations(), // requis par p-table (animations de tri/expand)
provideHttpClient(), // requis pour le mode lazy
],
};
Importer le thème CSS dans angular.json
// angular.json — section "styles"
"styles": [
"node_modules/primeng/resources/themes/lara-light-blue/theme.css",
"node_modules/primeng/resources/primeng.min.css",
"node_modules/primeicons/primeicons.css",
"src/styles.css"
]
Premier composant avec p-table
// users-table.component.ts
import { Component, signal } from '@angular/core';
import { TableModule } from 'primeng/table';
interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'editor' | 'viewer';
active: boolean;
}
@Component({
selector: 'app-users-table',
standalone: true,
imports: [TableModule],
template: `
<p-table [value]="users()" [paginator]="true" [rows]="10">
<ng-template pTemplate="header">
<tr>
<th>Nom</th>
<th>Email</th>
<th>Rôle</th>
<th>Actif</th>
</tr>
</ng-template>
<ng-template pTemplate="body" let-u>
<tr>
<td>{{ u.name }}</td>
<td>{{ u.email }}</td>
<td>{{ u.role }}</td>
<td>{{ u.active ? 'Oui' : 'Non' }}</td>
</tr>
</ng-template>
</p-table>
`,
})
export class UsersTableComponent {
users = signal<User[]>([
{ id: 1, name: 'Alice Martin', email: 'alice@demo.fr', role: 'admin', active: true },
{ id: 2, name: 'Bruno Dupont', email: 'bruno@demo.fr', role: 'editor', active: true },
{ id: 3, name: 'Clara Lefevre', email: 'clara@demo.fr', role: 'viewer', active: false },
]);
}
pTemplate reste la directive officielle pour cibler les zones header, body, footer, caption, summary, emptymessage et paginatorleft / paginatorright.
Tri simple, multi-colonnes, custom
Tri simple — pSortableColumn
<p-table [value]="products()" sortMode="single">
<ng-template pTemplate="header">
<tr>
<th pSortableColumn="name">
Nom <p-sortIcon field="name"></p-sortIcon>
</th>
<th pSortableColumn="price">
Prix <p-sortIcon field="price"></p-sortIcon>
</th>
<th pSortableColumn="stock">
Stock <p-sortIcon field="stock"></p-sortIcon>
</th>
</tr>
</ng-template>
<ng-template pTemplate="body" let-p>
<tr>
<td>{{ p.name }}</td>
<td>{{ p.price | currency:'EUR' }}</td>
<td>{{ p.stock }}</td>
</tr>
</ng-template>
</p-table>
Tri multi-colonnes (touche Maj enfoncée)
<p-table
[value]="products()"
sortMode="multiple"
[multiSortMeta]="initialSort">
<!-- même structure header/body que ci-dessus -->
</p-table>
// products-list.component.ts
import { SortMeta } from 'primeng/api';
export class ProductsListComponent {
// Tri initial : par catégorie ASC puis prix DESC
initialSort: SortMeta[] = [
{ field: 'category', order: 1 },
{ field: 'price', order: -1 },
];
}
Tri personnalisé — événement (sortFunction)
<p-table
[value]="orders()"
[customSort]="true"
(sortFunction)="customSort($event)">
<!-- ... -->
</p-table>
customSort(event: any): void {
// event.data : tableau à trier — event.field : champ — event.order : 1 ou -1
event.data.sort((a: any, b: any) => {
// Tri spécial pour le champ "priority" : critical > high > normal > low
const priorities = { critical: 4, high: 3, normal: 2, low: 1 };
const va = priorities[a[event.field] as keyof typeof priorities] ?? 0;
const vb = priorities[b[event.field] as keyof typeof priorities] ?? 0;
return (va - vb) * event.order;
});
}
<. Les chaînes ISO ("2026-05-09") se trient correctement par ordre alphabétique. Pour des objets Date, utilisez customSort et comparez via .getTime().
Filtres : par colonne et global
Filtre global sur plusieurs champs
<div class="d-flex justify-content-end mb-2">
<span class="p-input-icon-left">
<i class="pi pi-search"></i>
<input
pInputText
type="text"
placeholder="Rechercher..."
(input)="dt.filterGlobal($any($event.target).value, 'contains')"
/>
</span>
</div>
<p-table
#dt
[value]="users()"
[globalFilterFields]="['name', 'email', 'role']"
[paginator]="true"
[rows]="10">
<!-- header / body -->
</p-table>
Filtre par colonne avec p-columnFilter
<p-table [value]="orders()" [paginator]="true" [rows]="10">
<ng-template pTemplate="header">
<tr>
<th>
Référence
<p-columnFilter type="text" field="ref" matchMode="contains"></p-columnFilter>
</th>
<th>
Statut
<p-columnFilter field="status" matchMode="equals" [showMenu]="false">
<ng-template pTemplate="filter" let-value let-filter="filterCallback">
<p-dropdown
[options]="statusOptions"
[ngModel]="value"
(onChange)="filter($event.value)"
placeholder="Tous"
[showClear]="true">
</p-dropdown>
</ng-template>
</p-columnFilter>
</th>
<th>
Total HT
<p-columnFilter type="numeric" field="totalHT" currency="EUR"></p-columnFilter>
</th>
<th>
Date
<p-columnFilter type="date" field="createdAt" matchMode="dateAfter"></p-columnFilter>
</th>
</tr>
</ng-template>
<!-- body -->
</p-table>
Modes de correspondance disponibles
| matchMode | Description | Type cible |
|---|---|---|
contains | Contient la sous-chaîne | text |
startsWith / endsWith | Commence / finit par | text |
equals / notEquals | Égalité stricte | tous |
lt / lte / gt / gte | Comparaisons numériques | numeric |
dateBefore / dateAfter | Avant / après une date | date |
between | Entre deux bornes | numeric / date |
in | Valeur dans une liste | tous |
Persistance des filtres en localStorage
<p-table
#dt
[value]="orders()"
stateStorage="local"
stateKey="orders-table-state"
[paginator]="true"
[rows]="10">
<!-- ... -->
</p-table>
stateStorage="local" sauvegarde tri, filtres, pagination et largeur de colonnes dans localStorage. stateStorage="session" utilise sessionStorage. Idéal pour reprendre une session de tri complexe sans recoder une persistance.
Pagination et templates
Pagination standard
<p-table
[value]="orders()"
[paginator]="true"
[rows]="10"
[rowsPerPageOptions]="[10, 25, 50, 100]"
[showCurrentPageReport]="true"
currentPageReportTemplate="{first} – {last} sur {totalRecords} commandes">
<!-- ... -->
</p-table>
Boutons personnalisés autour du paginateur
<p-table [value]="orders()" [paginator]="true" [rows]="10">
<ng-template pTemplate="paginatorleft">
<button class="btn btn-sm btn-outline-primary me-2" (click)="refresh()">
<i class="pi pi-refresh"></i> Recharger
</button>
</ng-template>
<ng-template pTemplate="paginatorright">
<span class="text-muted">Mise à jour : {{ lastRefresh() | date:'HH:mm:ss' }}</span>
</ng-template>
<!-- header / body -->
</p-table>
Message vide personnalisé
<ng-template pTemplate="emptymessage">
<tr>
<td colspan="5" class="text-center py-5">
<i class="pi pi-inbox text-muted" style="font-size:3rem"></i>
<p class="mt-3 text-muted">Aucune commande ne correspond aux filtres.</p>
<button class="btn btn-sm btn-outline-secondary" (click)="dt.clear()">
Réinitialiser les filtres
</button>
</td>
</tr>
</ng-template>
Lazy load — pagination côté serveur
Quand votre dataset dépasse quelques milliers de lignes, charger toutes les données en mémoire devient inefficace. Le mode [lazy]="true" délègue tri, filtres et pagination à votre API backend.
Composant complet en mode lazy
// orders-page.component.ts
import { Component, inject, signal } from '@angular/core';
import { TableModule, TableLazyLoadEvent } from 'primeng/table';
import { OrdersService } from './orders.service';
import { Order } from './order.model';
@Component({
selector: 'app-orders-page',
standalone: true,
imports: [TableModule],
template: `
<p-table
[value]="orders()"
[lazy]="true"
[paginator]="true"
[rows]="20"
[totalRecords]="total()"
[loading]="loading()"
(onLazyLoad)="loadOrders($event)">
<ng-template pTemplate="header">
<tr>
<th pSortableColumn="ref">Réf <p-sortIcon field="ref"/></th>
<th pSortableColumn="customer">Client <p-sortIcon field="customer"/></th>
<th pSortableColumn="total">Total <p-sortIcon field="total"/></th>
<th pSortableColumn="createdAt">Date <p-sortIcon field="createdAt"/></th>
</tr>
</ng-template>
<ng-template pTemplate="body" let-o>
<tr>
<td>{{ o.ref }}</td>
<td>{{ o.customer }}</td>
<td>{{ o.total | currency:'EUR' }}</td>
<td>{{ o.createdAt | date:'dd/MM/yyyy' }}</td>
</tr>
</ng-template>
</p-table>
`,
})
export class OrdersPageComponent {
private api = inject(OrdersService);
orders = signal<Order[]>([]);
total = signal<number>(0);
loading = signal<boolean>(false);
loadOrders(event: TableLazyLoadEvent): void {
this.loading.set(true);
// event contient : first, rows, sortField, sortOrder, filters
const params = {
offset: event.first ?? 0,
limit: event.rows ?? 20,
sortField: (event.sortField as string) ?? 'createdAt',
sortOrder: event.sortOrder ?? -1,
filters: event.filters,
};
this.api.search(params).subscribe(res => {
this.orders.set(res.items);
this.total.set(res.totalCount);
this.loading.set(false);
});
}
}
Service backend correspondant
// orders.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
export interface OrderSearchResult {
items: Order[];
totalCount: number;
}
@Injectable({ providedIn: 'root' })
export class OrdersService {
private http = inject(HttpClient);
search(p: any): Observable<OrderSearchResult> {
let params = new HttpParams()
.set('offset', p.offset)
.set('limit', p.limit)
.set('sortField', p.sortField)
.set('sortOrder', p.sortOrder);
// Sérialiser les filtres p-table en query params API
if (p.filters?.['ref']?.value) {
params = params.set('ref_contains', p.filters['ref'].value);
}
return this.http.get<OrderSearchResult>('/api/orders', { params });
}
}
onLazyLoad. Pour limiter les appels API, ajoutez filterDelay="300" sur la <p-table> — le délai de 300 ms évite des dizaines de requêtes inutiles.
Virtual scroll : 10 000 lignes fluides
Pour afficher des datasets massifs sans pagination (logs, données de capteurs, time series), le virtual scroll ne rend que les lignes visibles dans le viewport. PrimeNG s'appuie sur le CDK Virtual Scroll d'Angular.
<p-table
[value]="logs()"
[scrollable]="true"
scrollHeight="500px"
[virtualScroll]="true"
[virtualScrollItemSize]="38">
<ng-template pTemplate="header">
<tr>
<th style="width:140px">Timestamp</th>
<th style="width:80px">Niveau</th>
<th>Message</th>
</tr>
</ng-template>
<ng-template pTemplate="body" let-log>
<tr style="height:38px">
<td>{{ log.ts | date:'HH:mm:ss.SSS' }}</td>
<td>
<span class="badge" [ngClass]="logBadge(log.level)">
{{ log.level }}
</span>
</td>
<td>{{ log.message }}</td>
</tr>
</ng-template>
</p-table>
virtualScrollItemSizedoit correspondre exactement à la hauteur en pixels de chaque ligne- Définir
scrollHeight(px ou flex) — sans hauteur, le virtual scroll ne s'active pas - Utiliser une fonction
trackBypour stabiliser les row recycling - Éviter les hauteurs variables — désactivez le wrap CSS sur les cellules ou utilisez
text-overflow:ellipsis
Sélection de lignes
Sélection multiple avec cases à cocher
// users-selection.component.ts
import { Component, signal } from '@angular/core';
import { TableModule } from 'primeng/table';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-users-selection',
standalone: true,
imports: [TableModule, FormsModule],
template: `
<div class="mb-3 d-flex gap-2">
<button class="btn btn-sm btn-outline-danger"
[disabled]="selected().length === 0"
(click)="deleteSelected()">
Supprimer ({{ selected().length }})
</button>
<button class="btn btn-sm btn-outline-secondary" (click)="selected.set([])">
Désélectionner tout
</button>
</div>
<p-table
[value]="users()"
[(selection)]="selectedRows"
dataKey="id"
[paginator]="true" [rows]="10">
<ng-template pTemplate="header">
<tr>
<th style="width:3rem">
<p-tableHeaderCheckbox></p-tableHeaderCheckbox>
</th>
<th>Nom</th><th>Email</th>
</tr>
</ng-template>
<ng-template pTemplate="body" let-u>
<tr>
<td><p-tableCheckbox [value]="u"></p-tableCheckbox></td>
<td>{{ u.name }}</td>
<td>{{ u.email }}</td>
</tr>
</ng-template>
</p-table>
`,
})
export class UsersSelectionComponent {
users = signal<User[]>([/* ... */]);
selectedRows: User[] = [];
selected = signal<User[]>([]);
deleteSelected(): void {
const ids = this.selectedRows.map(u => u.id);
this.users.update(list => list.filter(u => !ids.includes(u.id)));
this.selectedRows = [];
}
}
[(selection)]="selectedRows" requiert que selectedRows soit une référence stable (propriété de classe, pas un Signal directement). Pour synchroniser avec un Signal, utilisez (selectionChange)="selected.set($event)".
Export CSV / Excel / PDF
Export CSV natif
<div class="d-flex gap-2 mb-2">
<button class="btn btn-sm btn-outline-primary" (click)="dt.exportCSV()">
<i class="pi pi-file"></i> CSV (toutes lignes filtrées)
</button>
<button class="btn btn-sm btn-outline-primary"
(click)="dt.exportCSV({ selectionOnly: true })"
[disabled]="selectedRows.length === 0">
<i class="pi pi-check-square"></i> CSV (sélection)
</button>
</div>
<p-table
#dt
[value]="orders()"
[exportFilename]="'commandes-' + (today | date:'yyyy-MM-dd')">
<!-- ... -->
</p-table>
Export Excel via SheetJS
// orders-export.component.ts
import { Component, viewChild, signal } from '@angular/core';
import { Table, TableModule } from 'primeng/table';
import * as XLSX from 'xlsx';
@Component({
selector: 'app-orders-export',
standalone: true,
imports: [TableModule],
template: `
<button class="btn btn-success btn-sm mb-2" (click)="exportExcel()">
<i class="pi pi-file-excel"></i> Export Excel
</button>
<p-table #dt [value]="orders()"><!-- ... --></p-table>
`,
})
export class OrdersExportComponent {
dt = viewChild.required<Table>('dt');
orders = signal<Order[]>([/* ... */]);
exportExcel(): void {
// dt.filteredValue contient les données après filtres
const data = this.dt().filteredValue ?? this.orders();
const ws = XLSX.utils.json_to_sheet(data);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Commandes');
XLSX.writeFile(wb, `commandes-${new Date().toISOString().slice(0, 10)}.xlsx`);
}
}
Export PDF avec jsPDF + autoTable
import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
exportPDF(): void {
const data = this.dt().filteredValue ?? this.orders();
const doc = new jsPDF();
doc.setFontSize(14);
doc.text('Liste des commandes', 14, 15);
autoTable(doc, {
head: [['Réf', 'Client', 'Total', 'Date']],
body: data.map(o => [
o.ref, o.customer, o.total.toFixed(2) + ' €',
new Date(o.createdAt).toLocaleDateString('fr-FR'),
]),
startY: 25,
theme: 'striped',
});
doc.save(`commandes-${Date.now()}.pdf`);
}
Optimisations et bonnes pratiques
trackBy pour limiter les re-renders
<p-table [value]="users()" [trackBy]="trackById">
<!-- ... -->
</p-table>
// trackBy : Angular ne ré-instancie que les lignes dont l'id a changé
trackById = (_index: number, item: User) => item.id;
OnPush + Signals = 0 zone.js inutile
import { ChangeDetectionStrategy } from '@angular/core';
@Component({
// ...
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class OrdersPageComponent { /* ... */ }
Charger PrimeNG en lazy loading par route
// app.routes.ts
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: 'orders',
// Le bundle PrimeNG TableModule n'est téléchargé qu'à l'ouverture de /orders
loadComponent: () => import('./orders/orders-page.component')
.then(m => m.OrdersPageComponent),
},
];
- Toujours définir
trackBysur les listes > 100 lignes - Activer
ChangeDetectionStrategy.OnPushsur le composant parent - Mode
lazydès que le dataset dépasse 1000 lignes - Mode
virtualScrollpour des affichages sans pagination > 5000 lignes - Utiliser
filterDelay="300"pour debouncer les filtres serveur - Charger PrimeNG en lazy loading par route via
loadComponent - Préférer les Signals pour
[value]— déclenche moins de cycles CD
Conclusion
<p-table> couvre 90 % des besoins d'affichage tabulaire en entreprise sans avoir à recoder tri, filtres ou pagination. Du simple tableau de 50 lignes à la grille temps réel de 100 000 entrées, le composant s'adapte via trois leviers : paginator pour les volumes modestes, lazy pour les API paginées, et virtualScroll pour les datasets massifs.
Combiné avec les Signals d'Angular 17+, le mode standalone et OnPush, p-table reste fluide même avec des opérations complexes (filtres multi-critères, tri multi-colonnes, sélection persistée). Pensez à stateStorage pour offrir à vos utilisateurs une expérience qui se souvient de leurs préférences entre deux sessions.