Angular Components in Practice: Build a Reusable “Smart Table” with Sorting, Filtering, and Pagination

Angular Components in Practice: Build a Reusable “Smart Table” with Sorting, Filtering, and Pagination

Angular apps often end up with the same UI problem repeated across screens: lists of things. Users want to search, sort by columns, and page through results. Developers want something reusable that doesn’t turn into a copy-pasted mess.

In this hands-on guide, you’ll build a reusable <app-smart-table> component that supports:

  • Client-side sorting (clickable column headers)
  • Text filtering (search across specific fields)
  • Pagination (page size + page navigation)
  • Clean inputs/outputs so it’s easy to reuse

This is a practical pattern for junior/mid devs: keep the component generic, but strongly typed, and use OnPush to avoid unnecessary re-renders.

What We’re Building

The component API will look like this:

  • [rows]: your array of data
  • [columns]: column definitions (header + accessor)
  • [pageSizeOptions]: allowed page sizes
  • (rowClick): emitted when a row is clicked

Example usage:

<app-smart-table [rows]="users" [columns]="userColumns" [pageSizeOptions]="[5, 10, 25]" (rowClick)="openUser($event)" ></app-smart-table>

Step 1: Define Types (Strongly-Typed Columns)

Create a file smart-table.types.ts:

export type SortDirection = 'asc' | 'desc'; export interface SmartTableColumn<T> { /** Text shown in the header */ header: string; /** * Unique key for sorting and tracking. * Example: 'email', 'createdAt' */ key: string; /** * Returns the value to display in the cell. * Keep it simple: string/number/date, etc. */ accessor: (row: T) => unknown; /** * Optional: value used for sorting (defaults to accessor()). * Useful when accessor returns formatted text. */ sortValue?: (row: T) => string | number | Date | null; /** * Optional: value used for filtering (defaults to accessor()). * Useful to include hidden fields in search. */ filterValue?: (row: T) => string; /** Optional: disable sorting for this column */ sortable?: boolean; }

This pattern gives you flexible columns without forcing your table to know about user fields like firstName or role.

Step 2: Generate the Component

Generate a component:

ng generate component shared/smart-table --standalone

We’ll use a standalone component so it’s easy to drop into any feature module.

Step 3: Implement SmartTableComponent (Logic)

Edit smart-table.component.ts:

import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, computed, signal } from '@angular/core'; import { SmartTableColumn, SortDirection } from './smart-table.types'; @Component({ selector: 'app-smart-table', standalone: true, imports: [CommonModule], templateUrl: './smart-table.component.html', styleUrls: ['./smart-table.component.css'], changeDetection: ChangeDetectionStrategy.OnPush }) export class SmartTableComponent<T> { // Inputs @Input({ required: true }) rows: T[] = []; @Input({ required: true }) columns: SmartTableColumn<T>[] = []; @Input() pageSizeOptions: number[] = [10, 25, 50]; // Outputs @Output() rowClick = new EventEmitter<T>(); // UI state via signals (Angular 16+) readonly query = signal(''); readonly sortKey = signal<string | null>(null); readonly sortDir = signal<SortDirection>('asc'); readonly pageSize = signal(10); readonly pageIndex = signal(0); // Derived data pipeline readonly filteredRows = computed(() => { const q = this.query().trim().toLowerCase(); if (!q) return this.rows; // Search across columns using filterValue/accessor return this.rows.filter(row => { return this.columns.some(col => { const raw = col.filterValue?.(row) ?? col.accessor(row); const text = (raw ?? '').toString().toLowerCase(); return text.includes(q); }); }); }); readonly sortedRows = computed(() => { const key = this.sortKey(); if (!key) return this.filteredRows(); const dir = this.sortDir(); const col = this.columns.find(c => c.key === key); if (!col) return this.filteredRows(); const getSortValue = (row: T) => { const v = col.sortValue?.(row) ?? col.accessor(row); return v ?? ''; }; const copy = [...this.filteredRows()]; copy.sort((a, b) => { const av = getSortValue(a); const bv = getSortValue(b); // Basic comparison that handles strings/numbers/dates const aa = av instanceof Date ? av.getTime() : av; const bb = bv instanceof Date ? bv.getTime() : bv; if (aa < bb) return dir === 'asc' ? -1 : 1; if (aa > bb) return dir === 'asc' ? 1 : -1; return 0; }); return copy; }); readonly pageCount = computed(() => { const total = this.sortedRows().length; return Math.max(1, Math.ceil(total / this.pageSize())); }); readonly pagedRows = computed(() => { const size = this.pageSize(); const index = this.pageIndex(); const start = index * size; const end = start + size; return this.sortedRows().slice(start, end); }); // Handlers setQuery(value: string) { this.query.set(value); this.pageIndex.set(0); // reset pagination on search } toggleSort(col: SmartTableColumn<T>) { if (col.sortable === false) return; const currentKey = this.sortKey(); if (currentKey !== col.key) { this.sortKey.set(col.key); this.sortDir.set('asc'); return; } // same column: flip direction this.sortDir.set(this.sortDir() === 'asc' ? 'desc' : 'asc'); } setPageSize(size: number) { this.pageSize.set(size); this.pageIndex.set(0); } nextPage() { this.pageIndex.set(Math.min(this.pageIndex() + 1, this.pageCount() - 1)); } prevPage() { this.pageIndex.set(Math.max(this.pageIndex() - 1, 0)); } goToPage(index: number) { this.pageIndex.set(Math.min(Math.max(index, 0), this.pageCount() - 1)); } onRowClick(row: T) { this.rowClick.emit(row); } trackByIndex = (i: number) => i; }

Notes:

  • signals + computed makes data flow easy to read: rows → filtered → sorted → paged.
  • OnPush improves performance, especially for large lists.
  • We reset page index on query/page size changes to avoid empty pages.

Step 4: Build the Template

Edit smart-table.component.html:

<div class="smart-table"> <div class="toolbar"> <input type="search" class="search" [value]="query()" (input)="setQuery(($any($event.target)).value)" placeholder="Search..." /> <div class="page-size"> <label>Page size:</label> <select [value]="pageSize()" (change)="setPageSize(+$any($event.target).value)" > <option *ngFor="let s of pageSizeOptions; trackBy: trackByIndex" [value]="s"> {{ s }} </option> </select> </div> </div> <table> <thead> <tr> <th *ngFor="let col of columns; trackBy: trackByIndex" (click)="toggleSort(col)" [class.sortable]="col.sortable !== false" > <span>{{ col.header }}</span> <span class="sort-indicator" *ngIf="sortKey() === col.key"> {{ sortDir() === 'asc' ? '▲' : '▼' }} </span> </th> </tr> </thead> <tbody> <tr *ngFor="let row of pagedRows(); trackBy: trackByIndex" (click)="onRowClick(row)" class="row" > <td *ngFor="let col of columns; trackBy: trackByIndex"> {{ col.accessor(row) }} </td> </tr> <tr *ngIf="pagedRows().length === 0"> <td [attr.colspan]="columns.length" class="empty"> No results found. </td> </tr> </tbody> </table> <div class="footer"> <div class="count"> Showing {{ pageIndex() * pageSize() + 1 }} - {{ Math.min((pageIndex() + 1) * pageSize(), sortedRows().length) }} of {{ sortedRows().length }} </div> <div class="pagination"> <button (click)="prevPage()" [disabled]="pageIndex() === 0">Prev</button> <span>Page {{ pageIndex() + 1 }} / {{ pageCount() }}</span> <button (click)="nextPage()" [disabled]="pageIndex() >= pageCount() - 1">Next</button> </div> </div> </div>

Step 5: Add Minimal Styling

Edit smart-table.component.css:

.smart-table { display: grid; gap: 0.75rem; } .toolbar { display: flex; gap: 1rem; align-items: center; justify-content: space-between; flex-wrap: wrap; } .search { min-width: 240px; padding: 0.5rem 0.6rem; } .page-size { display: flex; gap: 0.5rem; align-items: center; } table { width: 100%; border-collapse: collapse; } th, td { padding: 0.6rem 0.5rem; border-bottom: 1px solid #e7e7e7; text-align: left; vertical-align: top; } th.sortable { cursor: pointer; user-select: none; } .sort-indicator { margin-left: 0.5rem; font-size: 0.85em; } .row:hover { background: #fafafa; } .empty { padding: 1rem; text-align: center; color: #666; } .footer { display: flex; justify-content: space-between; gap: 1rem; align-items: center; flex-wrap: wrap; } .pagination { display: flex; gap: 0.75rem; align-items: center; } button[disabled] { opacity: 0.5; cursor: not-allowed; }

This keeps styling readable, but you can swap in your design system later.

Step 6: Use It in a Real Screen

Here’s a complete example using a User model. In your page component:

import { Component } from '@angular/core'; import { SmartTableComponent } from '../shared/smart-table/smart-table.component'; import { SmartTableColumn } from '../shared/smart-table/smart-table.types'; type User = { id: number; name: string; email: string; role: 'admin' | 'editor' | 'viewer'; createdAt: string; // ISO string for simplicity }; @Component({ selector: 'app-users-page', standalone: true, imports: [SmartTableComponent], template: ` <h1>Users</h1> <app-smart-table [rows]="users" [columns]="userColumns" [pageSizeOptions]="[5, 10, 25]" (rowClick)="openUser($event)" ></app-smart-table> ` }) export class UsersPageComponent { users: User[] = [ { id: 1, name: 'Ava', email: '[email protected]', role: 'admin', createdAt: '2024-12-01T10:00:00Z' }, { id: 2, name: 'Noah', email: '[email protected]', role: 'viewer', createdAt: '2025-01-15T09:00:00Z' }, { id: 3, name: 'Mia', email: '[email protected]', role: 'editor', createdAt: '2025-02-20T12:30:00Z' }, // ...more rows ]; userColumns: SmartTableColumn<User>[] = [ { header: 'Name', key: 'name', accessor: u => u.name, sortable: true }, { header: 'Email', key: 'email', accessor: u => u.email, sortable: true }, { header: 'Role', key: 'role', accessor: u => u.role, sortable: true }, { header: 'Created', key: 'createdAt', accessor: u => new Date(u.createdAt).toLocaleDateString(), sortable: true, sortValue: u => new Date(u.createdAt) // sort by real date, not formatted text } ]; openUser(user: User) { console.log('Clicked user:', user); // Navigate, open drawer, etc. } }

This demonstrates why sortValue is useful: you can display a pretty date but sort by the actual Date.

Common Pitfalls (and Fixes)

  • Sorting formatted values breaks numeric/date order.
    Use sortValue to return a number or Date for correct comparisons.

  • Search should include “hidden” text.
    Use filterValue to add search tokens (e.g., firstName + lastName, or role labels).

  • Pagination shows an empty page after filtering.
    Reset pageIndex to 0 when query changes (we did this in setQuery).

  • Performance issues on big lists.
    For 5k+ rows, consider server-side pagination/filtering. This component is meant for small/medium datasets.

Next Upgrade: Server-Side Mode

If your dataset is large, keep the same UI but move filtering/sorting/paging to the API:

  • Emit events like (stateChange) with { query, sortKey, sortDir, pageIndex, pageSize }
  • Fetch data from the backend and set [rows] to the returned page
  • Provide [totalCount] so the component can render correct page counts

You’ll keep the same mental model (table state → data), but scale to real production volumes.

Wrap-Up

You now have a reusable Angular component that solves a real “every screen needs a table” problem without copy-paste. The key ideas are:

  • Define a clear, typed column contract
  • Use a predictable pipeline: filter → sort → paginate
  • Keep UI state local and reset appropriately
  • Use OnPush (and signals) for cleaner, faster updates

Drop <app-smart-table> into a few pages, and you’ll immediately feel the payoff in consistency and speed.


Leave a Reply

Your email address will not be published. Required fields are marked *