O componentă generică are un parametru de tip — DataTable<T>, Select<T>, List<T> — și funcționează cu orice tip de date, păstrând siguranța tipurilor în template și în output-uri. Angular deduce T din input-ul pe care îl legi (de exemplu din [rows]), exact ca o funcție generică în TypeScript.
Exemplu: DataTable<T>
Tabelul se ocupă de comportament (sortare, selecție, rând gol), iar cine îl folosește dă coloanele (configurare tipizată) și, opțional, template-uri pentru celule speciale.
export interface Column<T> {
key: Extract<keyof T, string>; // doar chei reale din T
header: string;
sortable?: boolean;
align?: 'left' | 'right';
format?: (row: T) => string;
}
@Component({
selector: 'app-data-table',
imports: [NgTemplateOutlet],
template: `
<table>
<thead>
<tr>
@for (col of columns(); track col.key) { <th (click)="toggleSort(col.key)">{{ col.header }}</th> }
</tr>
</thead>
<tbody>
@for (row of sortedRows(); track trackBy()(row)) {
<tr (click)="rowClick.emit(row)">
@for (col of columns(); track col.key) {
<td>
@if (cellTemplates().get(col.key); as tpl) {
<ng-container [ngTemplateOutlet]="tpl" [ngTemplateOutletContext]="{ $implicit: row, value: row[col.key] }" />
} @else {
{{ col.format ? col.format(row) : row[col.key] }}
}
</td>
}
</tr>
}
</tbody>
</table>
`,
})
export class DataTable<T> { // ← parametrul de tip al componentei
readonly rows = input.required<readonly T[]>(); // ← de aici e dedus T
readonly columns = input.required<readonly Column<T>[]>();
readonly trackBy = input<(row: T) => unknown>((row) => row);
readonly rowClick = output<T>(); // ← output tipizat
private readonly cellDirectives = contentChildren(CellTemplate<T>);
protected readonly cellTemplates = computed(
() => new Map(this.cellDirectives().map((d) => [d.appCell(), d.template] as const)),
);
protected readonly sort = signal<{ key: keyof T; dir: 'asc' | 'desc' } | null>(null);
protected readonly sortedRows = computed(() => { /* sortare pe this.sort() */ });
}DataTable<UserDto>
| Ana Popescu | Cluj-Napoca | admin | 2021 |
| Mihai Ionescu | București | editor | 2022 |
| Ioana Dumitru | Iași | viewer | 2023 |
| Radu Stan | Timișoara | editor | 2020 |
| Elena Marin | Brașov | viewer | 2024 |
| Andrei Georgescu | Cluj-Napoca | admin | 2019 |
| Maria Constantin | Constanța | viewer | 2023 |
| Vlad Petrescu | Sibiu | editor | 2022 |
| Cristina Pop | Oradea | viewer | 2024 |
| Bogdan Rusu | Iași | viewer | 2021 |
Ultimul click, tipizat ca UserDto: — apasă pe un rând
DataTable<ProductDto>
| Categorie | ||||
|---|---|---|---|---|
| Laptop Pro 14 | laptop | ★ 4.7 | 4 buc. | 7.499 RON |
| Laptop Air 13 | laptop | ★ 4.4 | epuizat | 4.999 RON |
| Telefon X | phone | ★ 4.6 | 12 buc. | 3.999 RON |
| Telefon Mini | phone | ★ 4.1 | 7 buc. | 2.499 RON |
| Căști Studio | audio | ★ 4.8 | 20 buc. | 1.299 RON |
| Boxă Portabilă | audio | ★ 4 | epuizat | 449 RON |
| Mouse Ergo | accessory | ★ 4.5 | 35 buc. | 349 RON |
| Tastatură Mecanică | accessory | ★ 4.9 | 9 buc. | 699 RON |
| Hub USB-C | accessory | ★ 3.9 | 50 buc. | 229 RON |
| Monitor 27" | accessory | ★ 4.6 | 3 buc. | 1.899 RON |
Cum îl folosești
<app-data-table
[rows]="users()"
[columns]="userColumns"
[trackBy]="byId"
(rowClick)="open($event)" <!-- $event: UserDto -->
>
<ng-template appCell="role" [appCellOf]="users()" let-user>
<app-role-badge [role]="user.role" /> <!-- user: UserDto, autocomplete funcționează -->
</ng-template>
</app-data-table>Încă un exemplu: Select<T>
// Alt exemplu clasic: un Select<T> care merge cu orice obiect
@Component({
selector: 'app-select',
template: `
<select (change)="pick($any($event.target).selectedIndex - 1)">
<option disabled [selected]="!value()">{{ placeholder() }}</option>
@for (o of options(); track $index) {
<option [selected]="isSelected(o)">{{ displayWith()(o) }}</option>
}
</select>
`,
})
export class Select<T> {
readonly options = input.required<readonly T[]>();
readonly value = model<T | null>(null);
readonly displayWith = input<(item: T) => string>((item) => String(item));
readonly compareWith = input<(a: T, b: T) => boolean>((a, b) => a === b);
readonly placeholder = input('Alege…');
protected isSelected(o: T) {
const v = this.value();
return v !== null && this.compareWith()(o, v);
}
protected pick(i: number) {
this.value.set(this.options()[i] ?? null);
}
}
// <app-select [options]="countries()" [(value)]="country" [displayWith]="byName" />Compoziție: wrapper de domeniu peste componenta generică
Componenta generică nu trebuie să știe nimic despre domeniu. Când ai același tabel de useri în trei pagini, nu adaugi showRoleColumn în DataTable — faci un UsersTable în feature-ul users care îl înfășoară.
// Compoziție: componenta de domeniu înfășoară componenta generică.
// DataTable<T> stă în shared/ui; UsersTable stă în features/users/ui.
@Component({
selector: 'app-users-table',
imports: [DataTable, CellTemplate, RoleBadge],
template: `
<app-data-table [rows]="users()" [columns]="columns" [trackBy]="byId" clickable (rowClick)="open.emit($event)">
<ng-template appCell="role" [appCellOf]="users()" let-user>
<app-role-badge [role]="user.role" />
</ng-template>
</app-data-table>
`,
})
export class UsersTable {
readonly users = input.required<User[]>();
readonly open = output<User>();
protected readonly byId = (u: User) => u.id;
protected readonly columns: Column<User>[] = [/* coloanele de domeniu, într-un singur loc */];
}Anti-pattern: „god component”
// ✗ „Componenta generică” care a crescut necontrolat
@Component({ selector: 'app-table' })
export class Table {
@Input() data: any[] = []; // any → zero siguranță
@Input() showRoleColumn = false; // cunoștințe de domeniu în shared
@Input() roleColors: Record<string, string> = {};
@Input() isProductTable = false; // if-uri pe „ce fel de tabel sunt”
@Input() priceCurrency = 'RON';
@Input() highlightAdmins = true;
@Input() exportToExcel = false;
// ... încă 30 de input-uri
}
// Soluția: tipuri generice + template-uri pentru celule + wrapper-e de domeniu.Când o fac generică?
- Același comportament pe date diferite: tabel, listă, select, autocomplete, tree, paginator.
- Ai deja 2–3 utilizări reale (nu „poate o să trebuiască”).
- Poți descrie API-ul fără cuvinte de domeniu (fără „user”, „order”, „invoice”).
- Vrei s-o pui în
shared/uisau într-o librărie.
- Ai o singură utilizare — generalizarea prematură costă mai mult decât duplicarea.
- Diferențele sunt de comportament, nu de date (atunci ai două componente diferite).
- API-ul ar avea nevoie de flag-uri de tip
isProductTable. - E o componentă de domeniu (
InvoiceSummary) — rămâne în feature.
Configurare vs template-uri
- Configurare (
columnscuformat): pentru cazurile simple, 80% din coloane. - Template-uri (
appCell): pentru celule bogate (badge-uri, butoane, link-uri). - Proiecție (
<ng-content>): pentru zone fixe (toolbar, footer).
Pentru părți de interfață pe care le alege cel care folosește componenta, vezi ng-template & ngTemplateOutlet; pentru tipurile din spatele lui T, Interfețe & modele.
Capcane frecvente
- T dedus ca
unknown: dacă input-ul din care se deduce lipsește sau eany. Tipizează datele la sursă. - Template-urile
let-*suntanyfărăngTemplateContextGuard— adaugă directiva cu guard. track rowimplicit e ok pentru date stabile; pentru date reîncărcate de pe server dă untrackBype id.- Prea multe input-uri: peste ~8–10 e semnal că amesteci responsabilități. Împarte sau folosește template-uri.