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

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

Angular components are easy to start with and surprisingly easy to overcomplicate. In real projects, you want components that are:

  • Reusable (works across multiple pages)
  • Predictable (inputs flow in, outputs flow out)
  • Performant (doesn’t re-render the world on every keystroke)
  • Testable (logic can be verified without clicking around)

In this hands-on guide, you’ll build a reusable <app-smart-table> component that supports filtering, sorting, and pagination. You’ll implement it as a standalone component, use OnPush change detection for performance, and keep the logic clean enough to unit test.

What We’re Building

The component will accept:

  • rows: an array of objects (data)
  • columns: configuration describing headers + how to read values
  • optional defaults (page size, initial sort)

And it will emit:

  • rowClick when a user clicks a row (so parent decides what to do)

We’ll implement client-side sorting/filtering/pagination to keep things focused. The same UI patterns apply to server-side tables too (you’d just emit changes and fetch from an API).

Step 1: Define Types for Columns and Sort State

Create smart-table.types.ts:

export type SortDirection = 'asc' | 'desc'; export interface SortState { key: string; direction: SortDirection; } export interface ColumnDef<T> { key: string; // unique identifier (e.g. 'email') header: string; // header label value: (row: T) => string | number; // how to read/display the cell sortable?: boolean; }

This keeps your component flexible. Instead of hardcoding “row.email”, the parent provides a value() accessor.

Step 2: Build the Standalone Smart Table Component

Create smart-table.component.ts:

import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output, SimpleChanges, } from '@angular/core'; import { ColumnDef, SortDirection, SortState } from './smart-table.types'; @Component({ selector: 'app-smart-table', standalone: true, imports: [CommonModule], templateUrl: './smart-table.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export class SmartTableComponent<T extends Record<string, any>> implements OnChanges { @Input({ required: true }) rows: T[] = []; @Input({ required: true }) columns: ColumnDef<T>[] = []; @Input() pageSize = 10; @Input() initialSort?: SortState; @Output() rowClick = new EventEmitter<T>(); // UI state query = ''; page = 1; sort: SortState | null = null; // derived data visibleRows: T[] = []; totalRows = 0; totalPages = 1; ngOnChanges(changes: SimpleChanges): void { if (changes['initialSort'] && this.initialSort) { this.sort = { ...this.initialSort }; } this.recompute(); } onQueryChange(value: string) { this.query = value; this.page = 1; // reset pagination when filtering this.recompute(); } toggleSort(colKey: string) { const col = this.columns.find(c => c.key === colKey); if (!col?.sortable) return; if (!this.sort || this.sort.key !== colKey) { this.sort = { key: colKey, direction: 'asc' }; } else { // flip direction const next: SortDirection = this.sort.direction === 'asc' ? 'desc' : 'asc'; this.sort = { ...this.sort, direction: next }; } this.recompute(); } goToPage(nextPage: number) { const clamped = Math.max(1, Math.min(nextPage, this.totalPages)); if (clamped === this.page) return; this.page = clamped; this.recompute(); } private recompute() { const q = this.query.trim().toLowerCase(); // 1) filter let data = this.rows; if (q) { data = data.filter(row => { return this.columns.some(col => { const v = col.value(row); return String(v).toLowerCase().includes(q); }); }); } // 2) sort if (this.sort) { const { key, direction } = this.sort; const col = this.columns.find(c => c.key === key); if (col) { const dir = direction === 'asc' ? 1 : -1; data = [...data].sort((a, b) => { const av = col.value(a); const bv = col.value(b); // numeric sort if both are numbers if (typeof av === 'number' && typeof bv === 'number') { return (av - bv) * dir; } // string compare fallback return String(av).localeCompare(String(bv)) * dir; }); } } // 3) paginate this.totalRows = data.length; this.totalPages = Math.max(1, Math.ceil(this.totalRows / this.pageSize)); const start = (this.page - 1) * this.pageSize; const end = start + this.pageSize; this.visibleRows = data.slice(start, end); } emitRowClick(row: T) { this.rowClick.emit(row); } sortIndicator(colKey: string): string { if (!this.sort || this.sort.key !== colKey) return ''; return this.sort.direction === 'asc' ? '▲' : '▼'; } }

Key ideas junior/mid developers should internalize:

  • @Input data comes from parent; component shouldn’t mutate it.
  • Derived UI state is recomputed in one place (recompute()), making bugs easier to track.
  • OnPush reduces unnecessary change detection when used with immutable patterns (we use [...data].sort() to avoid mutating the array).

Step 3: Add the Template

Create smart-table.component.html:

<div class="table-toolbar"> <input type="text" placeholder="Filter..." [value]="query" (input)="onQueryChange(($any($event.target)).value)" /> <div class="meta"> <span>{{ totalRows }} rows</span> <span>Page {{ page }} / {{ totalPages }}</span> </div> </div> <table class="smart-table"> <thead> <tr> <th *ngFor="let col of columns" (click)="toggleSort(col.key)" [class.sortable]="col.sortable" > <span>{{ col.header }}</span> <span class="sort-indicator">{{ sortIndicator(col.key) }}</span> </th> </tr> </thead> <tbody> <tr *ngFor="let row of visibleRows" (click)="emitRowClick(row)"> <td *ngFor="let col of columns"> {{ col.value(row) }} </td> </tr> <tr *ngIf="visibleRows.length === 0"> <td [attr.colspan]="columns.length">No results</td> </tr> </tbody> </table> <div class="pager"> <button (click)="goToPage(1)" [disabled]="page === 1">First</button> <button (click)="goToPage(page - 1)" [disabled]="page === 1">Prev</button> <button (click)="goToPage(page + 1)" [disabled]="page === totalPages">Next</button> <button (click)="goToPage(totalPages)" [disabled]="page === totalPages">Last</button> </div>

You can style it however you like; the important part is that sorting is triggered by clicking a sortable header, filtering is immediate, and pagination stays stable.

Step 4: Use the Component in a Page

Create a page component (or use an existing one). Example users-page.component.ts:

import { Component } from '@angular/core'; import { SmartTableComponent } from './smart-table.component'; import { ColumnDef } from './smart-table.types'; type User = { id: number; name: string; email: string; role: 'admin' | 'editor' | 'viewer'; }; @Component({ selector: 'app-users-page', standalone: true, imports: [SmartTableComponent], template: ` <h3>Users</h3> <app-smart-table [rows]="users" [columns]="columns" [pageSize]="5" [initialSort]="{ key: 'name', direction: 'asc' }" (rowClick)="openUser($event)" ></app-smart-table> `, }) export class UsersPageComponent { users: User[] = [ { id: 1, name: 'Ava', email: '[email protected]', role: 'admin' }, { id: 2, name: 'Noam', email: '[email protected]', role: 'viewer' }, { id: 3, name: 'Lina', email: '[email protected]', role: 'editor' }, { id: 4, name: 'Sam', email: '[email protected]', role: 'viewer' }, { id: 5, name: 'Maya', email: '[email protected]', role: 'editor' }, { id: 6, name: 'Omar', email: '[email protected]', role: 'admin' }, ]; columns: ColumnDef<User>[] = [ { key: 'id', header: 'ID', value: u => u.id, sortable: true }, { key: 'name', header: 'Name', value: u => u.name, sortable: true }, { key: 'email', header: 'Email', value: u => u.email, sortable: true }, { key: 'role', header: 'Role', value: u => u.role, sortable: true }, ]; openUser(user: User) { // real app: route to details page or open a modal console.log('Clicked user:', user); } }

Notice what the parent controls:

  • Data source (users)
  • Column definitions (columns)
  • What “clicking a row” means (openUser)

And what the table controls:

  • Filter input, sort state, current page
  • Derived visibleRows

Step 5: Make It More “Production” Without Making It Hard

Here are practical upgrades you can add incrementally:

  • Debounce filtering: for large datasets, delay recompute while typing (e.g., 150–300ms).
  • Track rows by ID: use *ngFor="let row of visibleRows; trackBy: trackById" to reduce DOM churn.
  • Accessibility: add scope="col" on <th>, and keyboard support for sorting.
  • Server-side mode: instead of filtering locally, emit events like queryChange, sortChange, pageChange.

Example trackBy (add to component):

trackByIndex = (index: number) => index;

Or, if your rows have IDs:

trackById = (_: number, row: any) => row.id;

Step 6: Add a Focused Unit Test for the Core Logic

You don’t need to test Angular templates to gain confidence. Test the behavior: filtering + sorting + pagination. Here’s a basic Jasmine/Karma-style test. Create smart-table.component.spec.ts:

import { SmartTableComponent } from './smart-table.component'; import { ColumnDef } from './smart-table.types'; type Row = { id: number; name: string }; describe('SmartTableComponent', () => { let cmp: SmartTableComponent<Row>; const columns: ColumnDef<Row>[] = [ { key: 'id', header: 'ID', value: r => r.id, sortable: true }, { key: 'name', header: 'Name', value: r => r.name, sortable: true }, ]; beforeEach(() => { cmp = new SmartTableComponent<Row>(); cmp.columns = columns; cmp.pageSize = 2; cmp.rows = [ { id: 1, name: 'Zoe' }, { id: 2, name: 'Ava' }, { id: 3, name: 'Mia' }, ]; cmp.ngOnChanges({}); // trigger initial compute }); it('filters by query across columns', () => { cmp.onQueryChange('av'); expect(cmp.totalRows).toBe(1); expect(cmp.visibleRows[0].name).toBe('Ava'); }); it('sorts ascending then descending', () => { cmp.toggleSort('name'); // asc expect(cmp.visibleRows[0].name).toBe('Ava'); cmp.toggleSort('name'); // desc expect(cmp.visibleRows[0].name).toBe('Zoe'); }); it('paginates results', () => { expect(cmp.totalPages).toBe(2); expect(cmp.visibleRows.length).toBe(2); cmp.goToPage(2); expect(cmp.visibleRows.length).toBe(1); expect(cmp.visibleRows[0].id).toBe(3); }); });

This test is intentionally practical: it verifies the three things tables usually break.

Common Pitfalls (and How This Approach Avoids Them)

  • Mutating inputs: Sorting with array.sort() mutates; we copy via [...data].
  • Logic spread everywhere: Keeping derivation in recompute() prevents “state soup”.
  • Hardcoded data shape: Column accessors (value(row)) make the table reusable.
  • Overusing two-way binding: Simple one-way bindings + events keep behavior explicit.

Next Step: Convert to Server-Side Table (When You Need It)

When your dataset grows (thousands+ rows), you’ll likely switch to server-side. The UI stays the same; you just replace local recompute with events:

  • emit queryChange after debounce
  • emit sortChange when header clicked
  • emit pageChange when pagination changes

Then the parent fetches rows from an API endpoint using those parameters. This component is a solid base for that upgrade because its boundaries (inputs/outputs) are already clean.

If you want to keep improving it, add column-specific filters, custom cell templates, and a “loading” state—but only when you actually need them. A simple, reliable component beats a mega-component that nobody wants to touch.


Leave a Reply

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