Angular Material PRO Reusable Table Component

Angular Material PRO Reusable Table Component

This reusable table supports:

  • MatTable
  • MatPaginator (Server-side)
  • MatSort (Server-side)
  • ✅ Search with debounce (RxJS)
  • ✅ Dynamic Columns
  • ✅ Server-side filtering
  • ✅ Reusable for any module (Invoices / Users / Projects / Orders)

1️⃣ Install Angular Material

ng add @angular/material

2️⃣ Create Reusable Table Component

ng g component shared/material-data-table --standalone

3️⃣ Table Models (Same as before)

table-column.model.ts

export interface TableColumn<T> {
  key: keyof T | string;
  header: string;
  sortable?: boolean;
}

table-request.model.ts

export interface TableRequest {
  pageNumber: number;
  pageSize: number;
  search?: string;
  sortBy?: string;
  sortDir?: 'asc' | 'desc';
  filters?: Record<string, any>;
}

table-response.model.ts

export interface TableResponse<T> {
  data: T[];
  totalCount: number;
}

4️⃣ Angular Material Data Table Component (Reusable)

material-data-table.component.ts

import { CommonModule } from '@angular/common';
import { Component, Input, Output, EventEmitter, ViewChild, AfterViewInit } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { MatTableModule } from '@angular/material/table';
import { MatPaginator, MatPaginatorModule } from '@angular/material/paginator';
import { MatSort, MatSortModule } from '@angular/material/sort';
import { MatInputModule } from '@angular/material/input';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { TableColumn } from './table-column.model';
import { TableRequest } from './table-request.model';

@Component({
  selector: 'app-material-data-table',
  standalone: true,
  imports: [
    CommonModule,
    ReactiveFormsModule,
    MatTableModule,
    MatPaginatorModule,
    MatSortModule,
    MatInputModule,
    MatIconModule,
    MatButtonModule,
    MatProgressSpinnerModule
  ],
  templateUrl: './material-data-table.component.html'
})
export class MaterialDataTableComponent<T> implements AfterViewInit {
  @Input() columns: TableColumn<T>[] = [];
  @Input() data: T[] = [];
  @Input() totalCount = 0;
  @Input() loading = false;
  @Input() pageSizeOptions: number[] = [5, 10, 20, 50];

  @Output() requestChange = new EventEmitter<TableRequest>();

  @ViewChild(MatPaginator) paginator!: MatPaginator;
  @ViewChild(MatSort) sort!: MatSort;

  searchControl = new FormControl('');
  request: TableRequest = { pageNumber: 1, pageSize: 10, search: '', sortBy: '', sortDir: 'asc', filters: {} };

  get displayedColumns(): string[] {
    return this.columns.map(c => c.key.toString());
  }

  ngAfterViewInit(): void {
    // 🔥 Search with debounce
    this.searchControl.valueChanges
      .pipe(debounceTime(400), distinctUntilChanged())
      .subscribe(value => {
        this.request.search = value ?? '';
        this.request.pageNumber = 1;
        this.paginator.firstPage();
        this.emitRequest();
      });

    // 🔥 Sorting
    this.sort.sortChange.subscribe(s => {
      this.request.sortBy = s.active;
      this.request.sortDir = (s.direction || 'asc') as any;
      this.request.pageNumber = 1;
      this.paginator.firstPage();
      this.emitRequest();
    });

    // 🔥 Pagination
    this.paginator.page.subscribe(p => {
      this.request.pageNumber = p.pageIndex + 1;
      this.request.pageSize = p.pageSize;
      this.emitRequest();
    });

    // initial load
    this.emitRequest();
  }

  setFilter(key: string, value: any) {
    this.request.filters = { ...this.request.filters, [key]: value };
    this.request.pageNumber = 1;
    this.paginator.firstPage();
    this.emitRequest();
  }

  clearFilters() {
    this.request.filters = {};
    this.request.pageNumber = 1;
    this.paginator.firstPage();
    this.emitRequest();
  }

  private emitRequest() {
    this.requestChange.emit({ ...this.request });
  }

  getValue(row: any, key: string) {
    return row[key];
  }
}

material-data-table.component.html

<div style="padding: 12px; border-radius: 12px; border: 1px solid #ddd;">

  <!-- Search + Loader -->
  <div style="display:flex; gap:10px; align-items:center; margin-bottom:12px;">
    <mat-form-field appearance="outline" style="width: 300px;">
      <mat-label>Search</mat-label>
      <input matInput [formControl]="searchControl" placeholder="Type to search..." />
    </mat-form-field>

    <button mat-raised-button color="warn" (click)="clearFilters()">Clear Filters</button>

    <div *ngIf="loading" style="margin-left:auto;">
      <mat-spinner diameter="30"></mat-spinner>
    </div>
  </div>

  <!-- Table -->
  <table mat-table [dataSource]="data" matSort>

    <ng-container *ngFor="let col of columns" [matColumnDef]="col.key.toString()">
      <th mat-header-cell *matHeaderCellDef>
        <span *ngIf="col.sortable" mat-sort-header="{{ col.key.toString() }}">{{ col.header }}</span>
        <span *ngIf="!col.sortable">{{ col.header }}</span>
      </th>

      <td mat-cell *matCellDef="let row">{{ getValue(row, col.key.toString()) }}</td>
    </ng-container>

    <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
    <tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
  </table>

  <div *ngIf="!loading && data.length === 0" style="text-align:center; padding:15px;">
    No records found
  </div>

  <mat-paginator
    [length]="totalCount"
    [pageSize]="request.pageSize"
    [pageSizeOptions]="pageSizeOptions"
    showFirstLastButtons>
  </mat-paginator>

</div>

5️⃣ Parent Component Example (Invoices)

invoice.model.ts

export interface Invoice {
  id: number;
  invoiceNo: string;
  projectName: string;
  amount: number;
  status: string;
}

invoice-list.component.ts

import { Component } from '@angular/core';
import { MaterialDataTableComponent } from '../shared/material-data-table/material-data-table.component';
import { TableColumn } from '../shared/material-data-table/table-column.model';
import { TableRequest } from '../shared/material-data-table/table-request.model';
import { InvoiceService } from './invoice.service';
import { Invoice } from './invoice.model';

@Component({
  selector: 'app-invoice-list',
  standalone: true,
  imports: [MaterialDataTableComponent],
  template: `
    <h2>Invoices</h2>
    <button (click)="filterPaid()">Paid</button>
    <button (click)="filterPending()">Pending</button>

    <app-material-data-table
      [columns]="columns"
      [data]="data"
      [totalCount]="totalCount"
      [loading]="loading"
      (requestChange)="loadInvoices($event)">
    </app-material-data-table>
  `
})
export class InvoiceListComponent {
  columns: TableColumn<Invoice>[] = [
    { key: 'invoiceNo', header: 'Invoice No', sortable: true },
    { key: 'projectName', header: 'Project', sortable: true },
    { key: 'amount', header: 'Amount', sortable: true },
    { key: 'status', header: 'Status', sortable: true }
  ];

  data: Invoice[] = [];
  totalCount = 0;
  loading = false;

  constructor(private invoiceService: InvoiceService) {}

  loadInvoices(req: TableRequest) {
    this.loading = true;
    this.invoiceService.getInvoices(req).subscribe({
      next: (res) => { this.data = res.data; this.totalCount = res.totalCount; this.loading = false; },
      error: () => (this.loading = false)
    });
  }

  filterPaid() {}
  filterPending() {}
}

6️⃣ Invoice Service (Server Side)

import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { TableRequest } from '../shared/material-data-table/table-request.model';
import { TableResponse } from '../shared/material-data-table/table-response.model';
import { Invoice } from './invoice.model';

@Injectable({ providedIn: 'root' })
export class InvoiceService {
  private baseUrl = "https://localhost:7219/api/invoices";

  constructor(private http: HttpClient) {}

  getInvoices(req: TableRequest): Observable<TableResponse<Invoice>> {
    let params = new HttpParams()
      .set("pageNumber", req.pageNumber)
      .set("pageSize", req.pageSize);

    if (req.search) params = params.set("search", req.search);
    if (req.sortBy) params = params.set("sortBy", req.sortBy);
    if (req.sortDir) params = params.set("sortDir", req.sortDir);

    if (req.filters) {
      Object.keys(req.filters).forEach(key => {
        const value = req.filters![key];
        if (value !== null && value !== undefined && value !== '') {
          params = params.set(key, value);
        }
      });
    }

    return this.http.get<TableResponse<Invoice>>(this.baseUrl, { params });
  }
}

⚡ Important Fix (MatSort Header Issue)

For Angular Material, use *ngIf for sortable headers:

<th mat-header-cell *matHeaderCellDef>
  <span *ngIf="col.sortable" mat-sort-header="{{ col.key.toString() }}">{{ col.header }}</span>
  <span *ngIf="!col.sortable">{{ col.header }}</span>
</th>

✅ PRO Version: Custom Cell Templates (Actions)

<td mat-cell *matCellDef="let row">
  <ng-container *ngIf="col.key !== 'actions'; else actionTpl">
    {{ getValue(row, col.key.toString()) }}
  </ng-container>

  <ng-template #actionTpl>
    <button mat-icon-button (click)="edit.emit(row)">
      <mat-icon>edit</mat-icon>
    </button>
  </ng-template>
</td>

✅ Interview Q&A

  • Q: Why server-side table?
    ✅ For large data (50k+) client-side is slow.
  • Q: How to avoid too many API calls?
    ✅ Use debounceTime for search.
  • Q: How to reuse for multiple pages?
    ✅ Column config + requestChange event.

Comments

Popular posts from this blog

Debouncing & Throttling in RxJS: Optimizing API Calls and User Interactions

Promises in Angular

Comprehensive Guide to C# and .NET Core OOP Concepts and Language Features