Build a Reusable Angular Data Table (Sorting + Pagination + Loading) with Standalone Components
Almost every web app ends up needing “a table”: a list of users, orders, audit logs, products… and it always starts simple, then quickly grows into sorting, paging, loading states, and empty states. Instead of duplicating table logic across screens, you can build a reusable Angular table component that stays simple to use.
This hands-on guide shows how to create a standalone Angular <app-data-table> component that:
- Accepts data + column definitions
- Supports client-side sorting
- Supports client-side pagination
- Shows loading and empty states
- Emits events so you can later swap to server-side sorting/paging if needed
1) Define types for columns and paging
Create a data-table.types.ts so your component is strongly typed and easy to evolve:
// src/app/shared/data-table/data-table.types.ts export type SortDir = 'asc' | 'desc'; export type ColumnDef<T> = { key: keyof T; header: string; sortable?: boolean; // Optional: map row -> display value (e.g. format date) cell?: (row: T) => string | number; }; export type PageState = { page: number; // 1-based pageSize: number; // e.g. 10, 25, 50 }; export type SortState<T> = { key: keyof T | null; dir: SortDir; };
2) Generate a standalone table component
If you’re using Angular CLI:
ng g component shared/data-table --standalone
Then implement the component. The goal is: pass rows, pass columns, optionally pass loading. The component handles the rest.
// src/app/shared/data-table/data-table.component.ts import { CommonModule } from '@angular/common'; import { Component, EventEmitter, Input, Output, computed, signal } from '@angular/core'; import { ColumnDef, PageState, SortState } from './data-table.types'; @Component({ selector: 'app-data-table', standalone: true, imports: [CommonModule], templateUrl: './data-table.component.html', styleUrls: ['./data-table.component.css'], }) export class DataTableComponent<T extends Record<string, any>> { @Input({ required: true }) columns: ColumnDef<T>[] = []; @Input({ required: true }) rows: T[] = []; @Input() loading = false; // Initial state can be overridden by parent if you want (later) private pageState = signal<PageState>({ page: 1, pageSize: 10 }); private sortState = signal<SortState<T>>({ key: null, dir: 'asc' }); @Output() pageChange = new EventEmitter<PageState>(); @Output() sortChange = new EventEmitter<SortState<T>>(); readonly totalRows = computed(() => this.rows.length); readonly sortedRows = computed(() => { const { key, dir } = this.sortState(); if (!key) return this.rows; // copy to avoid mutating @Input data const copy = [...this.rows]; copy.sort((a, b) => { const av = a[key]; const bv = b[key]; // handle null/undefined consistently if (av == null && bv == null) return 0; if (av == null) return dir === 'asc' ? -1 : 1; if (bv == null) return dir === 'asc' ? 1 : -1; // compare numbers or strings if (typeof av === 'number' && typeof bv === 'number') { return dir === 'asc' ? av - bv : bv - av; } const as = String(av).toLowerCase(); const bs = String(bv).toLowerCase(); if (as < bs) return dir === 'asc' ? -1 : 1; if (as > bs) return dir === 'asc' ? 1 : -1; return 0; }); return copy; }); readonly pagedRows = computed(() => { const { page, pageSize } = this.pageState(); const start = (page - 1) * pageSize; return this.sortedRows().slice(start, start + pageSize); }); readonly totalPages = computed(() => { const { pageSize } = this.pageState(); return Math.max(1, Math.ceil(this.totalRows() / pageSize)); }); toggleSort(col: ColumnDef<T>) { if (!col.sortable) return; const current = this.sortState(); const nextKey = col.key; // If clicking a new column: start asc. // If clicking same column: toggle. let nextDir: 'asc' | 'desc' = 'asc'; if (current.key === nextKey) { nextDir = current.dir === 'asc' ? 'desc' : 'asc'; } const next: SortState<T> = { key: nextKey, dir: nextDir }; this.sortState.set(next); // reset to first page when sorting changes (common UX) this.pageState.set({ ...this.pageState(), page: 1 }); this.sortChange.emit(next); this.pageChange.emit(this.pageState()); } setPage(page: number) { const clamped = Math.min(Math.max(1, page), this.totalPages()); const next = { ...this.pageState(), page: clamped }; this.pageState.set(next); this.pageChange.emit(next); } setPageSize(pageSize: number) { const next = { page: 1, pageSize }; this.pageState.set(next); this.pageChange.emit(next); } // Helpful for template display sortIconFor(col: ColumnDef<T>) { const s = this.sortState(); if (s.key !== col.key) return '↕'; return s.dir === 'asc' ? '↑' : '↓'; } }
3) Build the template: headers, states, and controls
This HTML handles: loading, empty, a table, and a tiny pager.
<!-- src/app/shared/data-table/data-table.component.html --> <div class="table-shell"> <div class="table-toolbar"> <div class="meta"> <span>Rows: <strong>{{ totalRows() }}</strong></span> <span>Pages: <strong>{{ totalPages() }}</strong></span> </div> <label class="page-size"> Page size: <select (change)="setPageSize(+($any($event.target).value))"> <option [value]="10">10</option> <option [value]="25">25</option> <option [value]="50">50</option> </select> </label> </div> <div *ngIf="loading" class="state"> Loading… </div> <div *ngIf="!loading && totalRows() === 0" class="state"> No results found. </div> <table *ngIf="!loading && totalRows() > 0" class="table"> <thead> <tr> <th *ngFor="let col of columns" [class.sortable]="col.sortable" (click)="toggleSort(col)" title="Click to sort" > <span>{{ col.header }}</span> <span *ngIf="col.sortable" class="sort-icon">{{ sortIconFor(col) }}</span> </th> </tr> </thead> <tbody> <tr *ngFor="let row of pagedRows()"> <td *ngFor="let col of columns"> {{ col.cell ? col.cell(row) : row[col.key] }} </td> </tr> </tbody> </table> <div class="pager" *ngIf="!loading && totalRows() > 0"> <button (click)="setPage(1)">⏮ First</button> <button (click)="setPage($any(pageState).()?.page - 1)">◀ Prev</button> <span class="page-indicator"> Page {{ ($any(pageState)().page) }} / {{ totalPages() }} </span> <button (click)="setPage($any(pageState)().page + 1)">Next ▶</button> <button (click)="setPage(totalPages())">Last ⏭</button> </div> </div>
Note: Angular templates can’t directly read private signals unless exposed. To keep this short, the pager uses $any. In a production version, expose a readonly page = computed(...) and readonly pageSize = computed(...) for clean binding.
Add a tiny bit of CSS:
/* src/app/shared/data-table/data-table.component.css */ .table-shell { border: 1px solid #e5e7eb; border-radius: 12px; overflow: hidden; } .table-toolbar { display: flex; justify-content: space-between; padding: 12px; gap: 12px; align-items: center; } .meta { display: flex; gap: 12px; opacity: 0.85; } .state { padding: 16px; } .table { width: 100%; border-collapse: collapse; } th, td { padding: 10px 12px; border-top: 1px solid #e5e7eb; text-align: left; } th.sortable { cursor: pointer; user-select: none; } .sort-icon { margin-left: 6px; opacity: 0.7; } .pager { display: flex; gap: 8px; padding: 12px; border-top: 1px solid #e5e7eb; align-items: center; } .page-indicator { margin: 0 8px; }
4) Use it on a real page with HttpClient
Let’s wire it to a real API call so it feels like “actual app code”. Create a simple service:
// src/app/users/users.service.ts import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; export type UserRow = { id: number; name: string; email: string; createdAt: string; // ISO date }; @Injectable({ providedIn: 'root' }) export class UsersService { constructor(private http: HttpClient) {} listUsers(): Observable<UserRow[]> { // Example endpoint. Replace with your real API. return this.http.get<UserRow[]>('/api/users'); } }
Now create a standalone page component that loads users and passes them into the table:
// src/app/users/users-page.component.ts import { CommonModule } from '@angular/common'; import { Component } from '@angular/core'; import { finalize } from 'rxjs'; import { DataTableComponent } from '../shared/data-table/data-table.component'; import { ColumnDef } from '../shared/data-table/data-table.types'; import { UsersService, UserRow } from './users.service'; @Component({ selector: 'app-users-page', standalone: true, imports: [CommonModule, DataTableComponent], template: ` <h2>Users</h2> <app-data-table [columns]="columns" [rows]="rows" [loading]="loading" (sortChange)="onSort($event)" (pageChange)="onPage($event)" ></app-data-table> ` }) export class UsersPageComponent { loading = false; rows: UserRow[] = []; columns: ColumnDef<UserRow>[] = [ { key: 'id', header: 'ID', sortable: true }, { key: 'name', header: 'Name', sortable: true }, { key: 'email', header: 'Email', sortable: true }, { key: 'createdAt', header: 'Created', sortable: true, cell: (u) => new Date(u.createdAt).toLocaleDateString() } ]; constructor(private users: UsersService) { this.fetch(); } fetch() { this.loading = true; this.users.listUsers() .pipe(finalize(() => (this.loading = false))) .subscribe({ next: (data) => (this.rows = data), error: (err) => { console.error(err); this.rows = []; }, }); } onSort(sort: any) { // For now, table sorts client-side. // Later, if your API supports sorting, call fetch with sort params here. console.log('sortChange', sort); } onPage(page: any) { // For now, paging is client-side. // Later, call fetch with page/pageSize for server-side paging. console.log('pageChange', page); } }
5) Make it production-friendly (small upgrades that matter)
You’ve got a working table, but here are practical improvements you’ll likely want next:
- Expose state cleanly: publish
readonly page = computed(...)andreadonly pageSize = computed(...)so you don’t rely on$anyin templates. - Track rows by ID: use
*ngFor="let row of pagedRows(); trackBy: trackById"to reduce DOM churn for large tables. - Support row click: add an
@Output() rowClickand emit the clicked row. - Server-side mode: add an input like
[serverMode]="true". In that mode, the table only emitspageChange/sortChangeand does no client-side slicing/sorting. - Accessibility: add
aria-sortto sortable headers and ensure focus styles on header buttons.
6) Common pitfalls (and how to avoid them)
- Mutating
@Input()arrays: sorting in-place will surprise parent components. Always sort a copy (we did[...this.rows]). - Sorting mixed types: if a column might be numbers sometimes and strings other times, normalize in
cellor create a dedicatedsortValuecallback. - Huge datasets: client-side paging is fine for a few thousand rows, but for big tables you’ll want server-side paging + filtering.
Wrap-up
You now have a reusable Angular data table that junior and mid developers can confidently drop into pages: define columns, pass rows, and you get sorting, pagination, and clean UI states. The best part is that the API is future-proof: when your app outgrows client-side sorting/paging, you can switch to server-side by using the same sortChange and pageChange events to fetch from the backend.
If you want, I can extend this same component with filtering (text search + per-column filters) and a “server mode” toggle while keeping the public API simple.
Leave a Reply