PrimeNG có hơn 80 components — đủ để build gần như bất kỳ admin panel hay business application nào. Documentation của nó khá đầy đủ cho use case cơ bản. Nhưng từ "demo chạy được" đến "production chạy mượt" là khoảng cách xa, và documentation không cover hết.
Mình dùng PrimeNG cho 4 project production trong 3 năm. Bài viết này là những thứ mình ước biết sớm hơn — không phải liệt kê component, mà là tips thực chiến cho những tình huống cụ thể mà bạn sẽ gặp khi build app thật.
p-table: Component phức tạp nhất, cũng hữu ích nhất
Lazy loading — bắt buộc cho data lớn
Mặc định p-table load tất cả data vào memory rồi sort/filter/paginate ở client. 100 row thì ổn, 10.000 row thì browser lag, 100.000 row thì crash tab.
Server-side lazy loading giải quyết triệt để:
@Component({
template: `
<p-table
[value]="orders"
[lazy]="true"
[paginator]="true"
[rows]="20"
[totalRecords]="totalRecords"
[loading]="loading"
[rowsPerPageOptions]="[10, 20, 50]"
(onLazyLoad)="loadOrders($event)"
[globalFilterFields]="['orderNumber', 'customerName']"
sortMode="single"
[scrollable]="true"
scrollHeight="65vh">
<ng-template pTemplate="header">
<tr>
<th pSortableColumn="orderNumber">
Mã đơn <p-sortIcon field="orderNumber" />
</th>
<th pSortableColumn="customerName">
Khách hàng <p-sortIcon field="customerName" />
</th>
<th pSortableColumn="total">
Tổng tiền <p-sortIcon field="total" />
</th>
<th>Trạng thái</th>
<th>Ngày tạo</th>
</tr>
<tr>
<th><input pInputText type="text"
(input)="dt.filter($any($event.target).value, 'orderNumber', 'contains')"
placeholder="Tìm mã đơn" /></th>
<th><input pInputText type="text"
(input)="dt.filter($any($event.target).value, 'customerName', 'contains')"
placeholder="Tìm khách" /></th>
<th></th>
<th>
<p-dropdown [options]="statusOptions"
(onChange)="dt.filter($event.value, 'status', 'equals')"
placeholder="Tất cả" [showClear]="true" />
</th>
<th></th>
</tr>
</ng-template>
<ng-template pTemplate="body" let-order>
<tr>
<td>{{ order.orderNumber }}</td>
<td>{{ order.customerName }}</td>
<td>{{ order.total | currency:'VND' }}</td>
<td><p-tag [value]="order.status" [severity]="getStatusSeverity(order.status)" /></td>
<td>{{ order.createdAt | date:'dd/MM/yyyy HH:mm' }}</td>
</tr>
</ng-template>
<ng-template pTemplate="emptymessage">
<tr><td colspan="5" class="text-center p-4">Không tìm thấy đơn hàng</td></tr>
</ng-template>
</p-table>
`,
})
export class OrderListComponent {
orders: Order[] = [];
totalRecords = 0;
loading = false;
statusOptions = [
{ label: 'Pending', value: 'Pending' },
{ label: 'Processing', value: 'Processing' },
{ label: 'Completed', value: 'Completed' },
];
constructor(private orderService: OrderService) {}
loadOrders(event: TableLazyLoadEvent) {
this.loading = true;
// map PrimeNG event sang API params
const params: OrderQueryParams = {
page: (event.first ?? 0) / (event.rows ?? 20) + 1,
pageSize: event.rows ?? 20,
sortField: event.sortField as string ?? 'createdAt',
sortOrder: event.sortOrder === 1 ? 'asc' : 'desc',
filters: this.mapFilters(event.filters),
};
this.orderService.getOrders(params).subscribe({
next: (result) => {
this.orders = result.data;
this.totalRecords = result.totalCount;
this.loading = false;
},
error: () => {
this.loading = false;
},
});
}
private mapFilters(filters: any): Record<string, string> {
const mapped: Record<string, string> = {};
if (!filters) return mapped;
Object.keys(filters).forEach(key => {
const filter = filters[key];
// PrimeNG filter có thể là object hoặc array
const value = Array.isArray(filter) ? filter[0]?.value : filter?.value;
if (value !== null && value !== undefined && value !== '') {
mapped[key] = value;
}
});
return mapped;
}
getStatusSeverity(status: string): string {
return status === 'Completed' ? 'success'
: status === 'Processing' ? 'warning'
: 'info';
}
}
Cái hay: onLazyLoad fire mỗi khi user sort, filter, hoặc chuyển trang. PrimeNG gom tất cả thông tin (first, rows, sortField, sortOrder, filters) vào một event object. Bạn chỉ cần map sang API params.
p-table: trick ít người biết
Persist state qua URL. User sort theo "tổng tiền" rồi share link cho đồng nghiệp — đồng nghiệp mở ra cũng thấy sort đó:
// đọc state từ queryParams khi init
ngOnInit() {
this.route.queryParams.subscribe(params => {
if (params['sort']) this.defaultSortField = params['sort'];
if (params['page']) this.defaultPage = +params['page'];
});
}
// ghi state vào URL khi user thay đổi
loadOrders(event: TableLazyLoadEvent) {
this.router.navigate([], {
queryParams: {
sort: event.sortField,
order: event.sortOrder,
page: Math.floor((event.first ?? 0) / (event.rows ?? 20)) + 1,
},
queryParamsHandling: 'merge',
});
// ... load data
}
Export Excel/CSV. PrimeNG table có exportCSV() built-in nhưng chỉ export data hiện tại trên client. Với lazy table, cần gọi API lấy full data:
async exportAll() {
const allData = await firstValueFrom(
this.orderService.getOrders({ page: 1, pageSize: 999999 })
);
// dùng xlsx library hoặc backend generate
this.exportService.toExcel(allData.data, 'orders.xlsx');
}
Row expansion. Click row để mở chi tiết inline — load detail on-demand:
<p-table [value]="orders" dataKey="id" [expandedRowKeys]="expandedRows">
<ng-template pTemplate="body" let-order let-expanded="expanded">
<tr>
<td>
<button pButton type="button" pRipple
[icon]="expanded ? 'pi pi-chevron-down' : 'pi pi-chevron-right'"
(click)="onRowExpand(order)"
class="p-button-text p-button-sm" />
</td>
<td>{{ order.orderNumber }}</td>
<!-- ... -->
</tr>
</ng-template>
<ng-template pTemplate="rowexpansion" let-order>
<tr>
<td colspan="5">
<app-order-detail [orderId]="order.id" />
<!-- detail component tự load data khi render -->
</td>
</tr>
</ng-template>
</p-table>
Dynamic Dialog — mở dialog từ service
PrimeNG có hai cách tạo dialog: p-dialog trong template (static) và DialogService (dynamic). Production app nên dùng dynamic — gọn hơn, reusable hơn, không clutter template.
Setup
// app.config.ts
import { provideAnimations } from '@angular/platform-browser/animations';
import { DialogService } from 'primeng/dynamicdialog';
export const appConfig = {
providers: [
provideAnimations(),
DialogService,
],
};
Tạo dialog component
// edit-order-dialog.component.ts
@Component({
template: `
<div class="p-fluid">
<div class="field">
<label>Trạng thái</label>
<p-dropdown [options]="statusOptions"
[(ngModel)]="order.status" />
</div>
<div class="field">
<label>Ghi chú</label>
<textarea pInputTextarea [(ngModel)]="order.note"
rows="3"></textarea>
</div>
<div class="flex justify-end gap-2 mt-4">
<button pButton label="Hủy" class="p-button-text"
(click)="cancel()" />
<button pButton label="Lưu" (click)="save()" />
</div>
</div>
`,
})
export class EditOrderDialogComponent {
order: any;
statusOptions = [...];
constructor(
private ref: DynamicDialogRef,
private config: DynamicDialogConfig
) {
// nhận data từ caller
this.order = { ...this.config.data.order };
}
save() {
this.ref.close(this.order); // trả kết quả về caller
}
cancel() {
this.ref.close(null); // đóng không trả gì
}
}
Mở dialog từ bất kỳ đâu
// order-list.component.ts
export class OrderListComponent {
private dialogService = inject(DialogService);
editOrder(order: Order) {
const ref = this.dialogService.open(EditOrderDialogComponent, {
header: `Sửa đơn ${order.orderNumber}`,
width: '500px',
data: { order },
contentStyle: { overflow: 'visible' }, // cho dropdown không bị cắt
closeOnEscape: true,
dismissableMask: true,
});
ref.onClose.subscribe((result: Order | null) => {
if (result) {
this.orderService.update(result).subscribe(() => {
this.messageService.add({
severity: 'success',
summary: 'Đã cập nhật đơn hàng',
});
this.reloadTable();
});
}
});
}
}
Pattern quan trọng: ref.onClose.subscribe() nhận kết quả từ dialog — giống MatDialog.afterClosed() nhưng PrimeNG style. Dialog component gọi ref.close(data) để trả data về.
Confirm trước khi đóng
User đang edit form trong dialog, bấm nhầm ra ngoài → mất data. Thêm guard:
// trong dialog component
constructor(private ref: DynamicDialogRef) {
// intercept close attempt
this.ref.onClose.subscribe(() => {
// chỉ fire nếu đóng từ bên ngoài (click mask, escape)
});
}
// hoặc disable dismissableMask khi form dirty
this.dialogService.open(EditComponent, {
dismissableMask: false, // không đóng khi click mask
closable: false, // không hiện nút X
// component tự quản lý close logic
});
Global toast service
// notification.service.ts — wrapper gọn cho MessageService
@Injectable({ providedIn: 'root' })
export class NotificationService {
private messageService = inject(MessageService);
success(detail: string, summary = 'Thành công') {
this.messageService.add({ severity: 'success', summary, detail, life: 3000 });
}
error(detail: string, summary = 'Lỗi') {
this.messageService.add({ severity: 'error', summary, detail, life: 8000 });
}
warn(detail: string, summary = 'Cảnh báo') {
this.messageService.add({ severity: 'warn', summary, detail, life: 5000 });
}
info(detail: string, summary = 'Thông báo') {
this.messageService.add({ severity: 'info', summary, detail, life: 3000 });
}
// HTTP error handler — dùng trong interceptor
handleHttpError(error: HttpErrorResponse) {
const message = error.error?.message ?? 'Đã xảy ra lỗi. Vui lòng thử lại.';
if (error.status === 0) {
this.error('Không thể kết nối server. Kiểm tra mạng.');
} else if (error.status === 401) {
this.warn('Phiên đăng nhập hết hạn. Vui lòng đăng nhập lại.');
} else if (error.status === 403) {
this.error('Bạn không có quyền thực hiện thao tác này.');
} else if (error.status >= 500) {
this.error('Server đang gặp sự cố. Vui lòng thử lại sau.');
} else {
this.error(message);
}
}
}
// http-error.interceptor.ts
export const httpErrorInterceptor: HttpInterceptorFn = (req, next) => {
const notification = inject(NotificationService);
return next(req).pipe(
catchError((error: HttpErrorResponse) => {
notification.handleHttpError(error);
return throwError(() => error);
})
);
};
Đặt <p-toast /> một lần trong app.component.html. Mọi notification từ service, interceptor, component đều hiển thị qua toast duy nhất đó.
Confirm dialog pattern
// wrapper cho ConfirmationService
@Injectable({ providedIn: 'root' })
export class ConfirmService {
private confirmationService = inject(ConfirmationService);
delete(entityName: string): Observable<boolean> {
return new Observable(subscriber => {
this.confirmationService.confirm({
message: `Bạn có chắc muốn xóa ${entityName}?`,
header: 'Xác nhận xóa',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Xóa',
rejectLabel: 'Hủy',
acceptButtonStyleClass: 'p-button-danger',
accept: () => { subscriber.next(true); subscriber.complete(); },
reject: () => { subscriber.next(false); subscriber.complete(); },
});
});
}
}
// sử dụng
deleteOrder(order: Order) {
this.confirmService.delete(`đơn hàng ${order.orderNumber}`)
.pipe(filter(confirmed => confirmed))
.subscribe(() => {
this.orderService.delete(order.id).subscribe(() => {
this.notify.success('Đã xóa đơn hàng');
this.reloadTable();
});
});
}
Wrap ConfirmationService thành Observable — chain được với RxJS, dùng filter(confirmed => confirmed) thay vì nested callback.
Form + PrimeNG Input — pattern gọn
Reusable form field wrapper
PrimeNG input components + Angular reactive forms + validation message = rất nhiều boilerplate mỗi field. Tạo wrapper:
// form-field.component.ts
@Component({
selector: 'app-form-field',
standalone: true,
imports: [CommonModule],
template: `
<div class="field" [class.invalid]="showError">
<label [for]="fieldId">
{{ label }}
@if (required) { <span class="text-red-500">*</span> }
</label>
<ng-content />
@if (showError) {
<small class="p-error">{{ errorMessage }}</small>
}
</div>
`,
})
export class FormFieldComponent {
label = input.required<string>();
control = input.required<AbstractControl>();
fieldId = input<string>('');
required = input(false);
showError = computed(() =>
this.control().invalid && (this.control().dirty || this.control().touched)
);
errorMessage = computed(() => {
const errors = this.control().errors;
if (!errors) return '';
if (errors['required']) return `${this.label()} bắt buộc nhập`;
if (errors['minlength']) return `Tối thiểu ${errors['minlength'].requiredLength} ký tự`;
if (errors['maxlength']) return `Tối đa ${errors['maxlength'].requiredLength} ký tự`;
if (errors['email']) return 'Email không hợp lệ';
if (errors['min']) return `Giá trị tối thiểu ${errors['min'].min}`;
if (errors['pattern']) return 'Định dạng không hợp lệ';
return 'Giá trị không hợp lệ';
});
}
Sử dụng:
<form [formGroup]="form">
<app-form-field label="Tên sản phẩm" [control]="form.controls.name" [required]="true">
<input pInputText formControlName="name" id="name" />
</app-form-field>
<app-form-field label="Giá" [control]="form.controls.price" [required]="true">
<p-inputNumber formControlName="price" mode="currency" currency="VND"
locale="vi-VN" [min]="0" />
</app-form-field>
<app-form-field label="Danh mục" [control]="form.controls.category" [required]="true">
<p-dropdown formControlName="category" [options]="categories"
optionLabel="name" optionValue="id" placeholder="Chọn danh mục" />
</app-form-field>
<app-form-field label="Mô tả" [control]="form.controls.description">
<p-editor formControlName="description" [style]="{ height: '200px' }" />
</app-form-field>
</form>
Mỗi form field: label, input (bất kỳ PrimeNG component), validation message — ba dòng thay vì mười dòng. Error message tự map từ validator name sang tiếng Việt.
Theme Customization — không hack CSS
Design Token (PrimeNG v17+)
PrimeNG v17 chuyển sang design token system. Thay vì override CSS selector cụ thể (dễ break khi PrimeNG update), đổi token:
// styles.scss
:root {
// primary color
--p-primary-color: #3b82f6;
--p-primary-contrast-color: #ffffff;
// surface colors
--p-surface-0: #ffffff;
--p-surface-50: #f8fafc;
--p-surface-100: #f1f5f9;
--p-surface-900: #0f172a;
// border radius
--p-border-radius: 8px;
// font
--p-font-family: 'Inter', sans-serif;
// specific component overrides
--p-button-padding-x: 1.25rem;
--p-button-padding-y: 0.625rem;
--p-inputtext-padding-x: 0.75rem;
--p-inputtext-padding-y: 0.5rem;
}
Dark mode toggle
// theme.service.ts
@Injectable({ providedIn: 'root' })
export class ThemeService {
private darkMode = signal(this.getInitialMode());
isDark = this.darkMode.asReadonly();
toggle() {
const newMode = !this.darkMode();
this.darkMode.set(newMode);
localStorage.setItem('darkMode', String(newMode));
this.applyTheme(newMode);
}
private getInitialMode(): boolean {
const stored = localStorage.getItem('darkMode');
if (stored !== null) return stored === 'true';
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
private applyTheme(dark: boolean) {
const html = document.documentElement;
if (dark) {
html.classList.add('p-dark');
} else {
html.classList.remove('p-dark');
}
}
}
PrimeNG v17+ hỗ trợ dark mode qua class p-dark trên <html>. Tất cả component tự switch — không cần swap stylesheet.
Virtual Scroll — dropdown 10.000 items
PrimeNG dropdown default render tất cả options vào DOM. 100 items ổn, 10.000 items thì scroll lag khủng khiếp:
<!-- ❌ lag với nhiều options -->
<p-dropdown [options]="allProducts" optionLabel="name" />
<!-- ✅ virtual scroll — chỉ render items visible -->
<p-dropdown [options]="allProducts" optionLabel="name"
[virtualScroll]="true"
[virtualScrollItemSize]="40"
[filter]="true"
filterPlaceholder="Tìm sản phẩm..." />
virtualScrollItemSize = chiều cao mỗi item (px). PrimeNG chỉ render items đang visible trong viewport, scroll mượt dù 100.000 items.
Tương tự cho p-table:
<p-table [value]="largeDataset"
[scrollable]="true" scrollHeight="500px"
[virtualScroll]="true"
[virtualScrollItemSize]="48">
Dropdown với server-side filter
10.000 items cũng không nên load hết lên client. Filter ở server:
@Component({
template: `
<p-dropdown
[options]="filteredProducts"
optionLabel="name"
optionValue="id"
[filter]="true"
(onFilter)="onFilter($event)"
[loading]="loading"
placeholder="Tìm và chọn sản phẩm"
emptyFilterMessage="Không tìm thấy"
[showClear]="true" />
`,
})
export class ProductPickerComponent {
filteredProducts: Product[] = [];
loading = false;
private searchSubject = new Subject<string>();
constructor(private productService: ProductService) {
this.searchSubject.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(term => {
this.loading = true;
return this.productService.search(term, 50); // max 50 results
})
).subscribe(products => {
this.filteredProducts = products;
this.loading = false;
});
}
onFilter(event: { filter: string }) {
this.searchSubject.next(event.filter);
}
}
Debounce 300ms, switchMap cancel request cũ — user gõ nhanh không spam API. Max 50 results đủ cho dropdown display.
Calendar & Date — gotchas
Timezone trap
<!-- ❌ PrimeNG Calendar trả Date object với LOCAL timezone -->
<p-calendar [(ngModel)]="selectedDate" />
<!-- user ở UTC+7 chọn "2026-03-15"
→ Date object = "2026-03-15T00:00:00+07:00"
→ gửi lên API, server ở UTC parse = "2026-03-14T17:00:00Z"
→ NGÀY SAI! -->
Fix: convert trước khi gửi API:
// date-utils.ts
export function toApiDate(date: Date): string {
// lấy local year/month/day, format YYYY-MM-DD
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
// hoặc dùng dateFormat của Calendar
<p-calendar [(ngModel)]="selectedDate" dateFormat="yy-mm-dd"
[appendTo]="'body'" /> <!-- appendTo body tránh bị container cắt -->
Date range picker
<p-calendar [(ngModel)]="dateRange"
selectionMode="range"
[readonlyInput]="true"
dateFormat="dd/mm/yy"
placeholder="Từ ngày — Đến ngày"
[showIcon]="true"
[appendTo]="'body'" />
selectionMode="range" — dateRange là Date[] có 2 phần tử: [startDate, endDate]. Khi user mới chọn start mà chưa chọn end, dateRange[1] là null — check trước khi gửi API.
Những cái bẫy phổ biến
Dropdown trong dialog bị cắt
<!-- ❌ dropdown popup bị cắt bởi dialog overflow -->
<p-dialog>
<p-dropdown [options]="items" />
</p-dialog>
<!-- ✅ append dropdown popup ra body -->
<p-dialog [contentStyle]="{ overflow: 'visible' }">
<p-dropdown [options]="items" appendTo="body" />
</p-dialog>
Mọi overlay component (dropdown, multiselect, calendar, autocomplete) bên trong dialog cần appendTo="body". Thiếu cái này, popup bị clip bởi dialog container. Bug này xuất hiện ở hầu hết project PrimeNG mình từng review.
Change detection performance
PrimeNG components dùng Default change detection. Trong table 1.000 row, mỗi change detection cycle Angular check tất cả row — dù chỉ 1 row thay đổi.
// table wrapper component dùng OnPush
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<p-table [value]="data()" ...>`
})
export class OrderTableComponent {
data = input.required<Order[]>();
}
Dumb component pattern — table component nhận data qua signal input, OnPush chỉ re-render khi input reference thay đổi.
Import đúng module
// ❌ import cả PrimeNG (bundle size khổng lồ)
import { PrimeNGModule } from 'primeng';
// ✅ import từng component (tree-shakeable)
import { TableModule } from 'primeng/table';
import { ButtonModule } from 'primeng/button';
import { DropdownModule } from 'primeng/dropdown';
import { DialogModule } from 'primeng/dialog';
PrimeNG không có barrel module gom tất cả (đúng rồi). Nhưng nhiều người tạo shared.module.ts import 30 PrimeNG modules rồi import shared module ở mọi nơi — cũng bad. Import đúng module cần dùng, trong đúng component cần dùng.
p-table filter không reset khi data thay đổi
// user chuyển tab "Pending" → "Completed", table vẫn giữ filter cũ
// ❌ chỉ đổi data
this.orders = newOrders;
// ✅ reset filter + sort khi context thay đổi
@ViewChild('dt') table!: Table;
changeTab(status: string) {
this.table.clear(); // reset sort, filter, pagination
this.loadOrders({ status });
}
table.clear() reset mọi state. Không gọi thì user thấy filter cũ apply trên data mới — kết quả confusing.
PrimeNG + TailwindCSS — chung sống hòa bình
Nhiều team dùng cả PrimeNG (components) lẫn Tailwind (utility CSS). Conflict chính: Tailwind reset và PrimeNG styles override nhau.
// tailwind.config.js
module.exports = {
// không preflight — tránh reset PrimeNG styles
corePlugins: {
preflight: false,
},
// hoặc dùng important selector
important: '#app',
};
// styles.scss — load order quan trọng
@import 'tailwindcss/base'; // ← Tailwind base
@import 'primeng/resources/themes/lara-light-blue/theme.css'; // ← PrimeNG theme
@import 'primeng/resources/primeng.css';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities'; // ← Tailwind utilities cuối cùng
Tailwind utilities load cuối nên override PrimeNG khi cần. PrimeNG theme load giữa nên không bị Tailwind base reset.
Tổng kết
PrimeNG mạnh ở breadth — 80+ components cover gần hết UI needs cho business app. Yếu ở documentation chi tiết cho production patterns — phần lớn phải tự khám phá qua trial and error.
Những tips quan trọng nhất từ kinh nghiệm mình: luôn dùng lazy loading cho p-table, dynamic dialog thay vì static dialog, wrapper component cho form fields, appendTo="body" cho mọi overlay trong dialog, OnPush cho table wrapper, và import đúng module thay vì import tất cả.
PrimeNG không hoàn hảo — một số component có quirk riêng mà documentation không mention. Nhưng khi bạn đã hiểu những quirk đó, nó là UI library productive nhất cho Angular business applications.
Leave a comment
Your email address will not be published. Required fields are marked *