If you've worked on more than one enterprise Angular application, you've probably seen this pattern: a CustomerTableComponent, an OrdersTableComponent, an InvoicesTableComponent, and three or four more that all look suspiciously similar. Each one sorts a little differently. Each one paginates a little differently. Each one was copy-pasted from the last one and adjusted "just enough" to ship.
None of that is a UI problem. It's an architecture problem — and it's worth treating it as one.
This is the complete version of that argument: why copy-paste tables become expensive, how to architect a generic, configurable Data Table around column definitions and content projection, a full working example you can adapt directly, a migration path for existing codebases, and answers to the questions that usually come up once a team tries this in practice.
Table of Contents
- Introduction
- The Real Cost of Copy Paste Tables
- Configuration Over Duplication
- Anatomy of a Generic Data Table Component
- Column Definitions as the Real API Surface
- Cell Templates and Content Projection
- Sorting and Filtering as Abstractions
- Pagination Client Side and Server Side
- Selection Architecture
- Performance Signals Computed and Virtual Scrolling
- Accessibility Considerations
- Design System Integration
- Testing a Platform Versus Testing a Page
- Common Pitfalls When Building Reusable Tables
- Putting It All Together
- Migrating an Existing Table to This Architecture
- Frequently Asked Questions
- The Senior Mindset Shift
- Wrapping Up
Introduction
A reusable table is not a table. It's a configurable UI platform capable of rendering different business domains without rewriting presentation logic.
That's a bigger claim than it sounds. Most teams don't set out to build five tables — they set out to ship a customer screen, then an orders screen, then an invoices screen, each on its own timeline, each under its own deadline pressure. The table component is rarely the bottleneck in the moment, so it gets copied instead of generalized. By the time the pattern is obvious, there are five or six components doing almost the same thing in almost different ways, and nobody owns the "almost."
This post walks through the architecture that avoids that outcome: a generic DataTable<T>, driven entirely by configuration, with sorting, filtering, pagination, and selection treated as abstractions rather than per-screen implementations.
The Real Cost of Copy Paste Tables
The first table is fast to build. The second is faster, because you copy the first one. By the fifth, you have five almost-identical components, each with its own sorting logic, its own pagination state, its own selection handling — and none of it shared.
The cost doesn't show up immediately. It shows up later:
- A bug in sorting logic gets fixed in one table and silently reappears in the other four.
- Onboarding a new developer means explaining five "slightly different" tables instead of one API.
- A design change — say, adding sticky headers — becomes five separate pull requests instead of one.
- A product requirement like "let users export selected rows" needs to be implemented five times, with five slightly different selection models to hook into.
None of this is visible in a sprint demo. It shows up months later, when "just add a column" turns into an afternoon of digging through duplicated logic to figure out which copy of the table you're even looking at.
A few signs a codebase already has this problem:
- Searching the repo for "Table" returns more than four or five components.
- Two tables paginate differently with no functional reason for the difference.
- A designer asks why one table looks different from another, and no one has a confident answer.
- A new hire's first question on a table-related ticket is "which one do I copy?"
Configuration Over Duplication
The fix isn't a better table component — it's a different question. Instead of asking "how do I build this table," ask "how do I build a table system this business domain can configure."
That reframing changes what gets built:
- Column definitions instead of hardcoded markup
- Cell templates instead of one-off formatting logic
-
A generic, typed
DataTable<T>instead of aCustomerTable,OrdersTable,InvoicesTable - Sorting, filtering, pagination, and selection as configurable behaviors, not duplicated implementations
Once configuration drives rendering, the same component can sit underneath your customer screen, your orders screen, and your invoices screen — without those teams ever touching each other's code.
Anatomy of a Generic Data Table Component
The foundation is a typed, generic component that knows nothing about your business domain. It only knows about columns, rows, and behavior.
export interface ColumnDef<T> {
key: keyof T | string;
header: string;
sortable?: boolean;
width?: string;
accessor?: (row: T) => unknown;
cellTemplate?: TemplateRef<{ $implicit: T; column: ColumnDef<T> }>;
}
@Component({
selector: 'app-data-table',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './data-table.component.html'
})
export class DataTableComponent<T> {
columns = input.required<ColumnDef<T>[]>();
rows = input.required<T[]>();
pageSize = input(25);
sortKey = signal<string | null>(null);
sortDirection = signal<'asc' | 'desc'>('asc');
currentPage = signal(0);
}
Nothing here mentions a customer, an order, or an invoice. That's the point — the domain lives entirely in the configuration that's passed in, not in the component itself.
Column Definitions as the Real API Surface
It's tempting to think of the markup as the table's API. In a reusable table, the column definitions are the API. They're what a feature team actually interacts with:
const customerColumns: ColumnDef<Customer>[] = [
{ key: 'name', header: 'Name', sortable: true },
{ key: 'email', header: 'Email' },
{ key: 'status', header: 'Status', cellTemplate: statusCellRef },
{ key: 'lifetimeValue', header: 'LTV', sortable: true, accessor: c => c.totals.lifetime }
];
This is a deliberately small surface: a key, a header, a sort flag, and an optional accessor or template. Keeping it small is what makes the table reusable across domains — the moment column definitions start carrying business logic, you're back to building one-off tables with extra steps.
Cell Templates and Content Projection
Real-world tables need more than text in a cell — status badges, action buttons, progress bars, avatars. Rather than branching on column type inside the table component, content projection lets the consumer decide what a cell renders, while the table only decides where.
<!-- consumer template -->
<app-data-table [columns]="columns" [rows]="customers()"></app-data-table>
<ng-template #statusCell let-row let-column="column">
<span class="badge" [class]="row.status">{{ row.status }}</span>
</ng-template>
<!-- inside data-table.component.html, per cell -->
@if (col.cellTemplate) {
<ng-container *ngTemplateOutlet="col.cellTemplate; context: { $implicit: row, column: col }"></ng-container>
} @else {
{{ resolveValue(row, col) }}
}
This is the difference between a table that knows about badges, chips, and progress bars, and a table that simply hosts whatever the consumer wants to render. The latter scales to new business domains without touching the table's source.
Sorting and Filtering as Abstractions
Sorting and filtering shouldn't live as imperative event handlers scattered through a component — they're cleaner as derived state. With Signals, computed() keeps the sorted and filtered rows predictable under OnPush, without manual change detection calls:
sortedRows = computed(() => {
const key = this.sortKey();
if (!key) return this.rows();
return [...this.rows()].sort((a, b) =>
this.compare(a, b, key) * (this.sortDirection() === 'asc' ? 1 : -1)
);
});
pagedRows = computed(() => {
const start = this.currentPage() * this.pageSize();
return this.sortedRows().slice(start, start + this.pageSize());
});
Sorting, filtering, and pagination become a pipeline of computed() signals layered on top of the raw rows() input — each one a pure function of the last, each one independently testable.
Pagination Client Side and Server Side
A table that only paginates in-memory data works for small datasets, but enterprise tables eventually need server-side pagination. The trick is abstracting where the data comes from, without changing the table's public API:
serverSide = input(false);
totalCount = input(0);
pageChange = output<{ page: number; pageSize: number; sortKey: string | null }>();
onPageChange(page: number) {
this.currentPage.set(page);
if (this.serverSide()) {
this.pageChange.emit({ page, pageSize: this.pageSize(), sortKey: this.sortKey() });
}
}
When serverSide is false, the table paginates its own computed() signal. When it's true, the table emits an event and trusts the consumer to supply the next page of rows(). Same component, two data-loading strategies, zero duplication.
Selection Architecture
Selection state deserves its own model, decoupled from rendering. A Set keyed by row identity keeps this simple and avoids re-rendering every row on every selection change:
selectedIds = signal<Set<string>>(new Set());
isSelected = (row: T) => this.selectedIds().has(this.getId(row));
toggleSelection(row: T) {
const next = new Set(this.selectedIds());
const id = this.getId(row);
next.has(id) ? next.delete(id) : next.add(id);
this.selectedIds.set(next);
}
Action columns (bulk delete, export, assign) then read from selectedIds() rather than walking the DOM or tracking checkbox state manually — selection becomes just another signal the rest of the UI can react to.
Performance Signals Computed and Virtual Scrolling
Performance in a reusable table isn't a separate optimization pass — it's a property of the architecture itself:
-
OnPush+ Signals mean the table only re-renders when an input signal or a derivedcomputed()actually changes. -
trackByprevents unnecessary DOM churn when rows update but identities stay the same. - CDK Virtual Scroll keeps large datasets performant by only rendering visible rows.
trackByFn = (row: T) => (row as any).id ?? row;
<cdk-virtual-scroll-viewport itemSize="48" class="table-viewport">
<tr *cdkVirtualFor="let row of pagedRows(); trackBy: trackByFn">
<!-- cells -->
</tr>
</cdk-virtual-scroll-viewport>
Because sorting, filtering, and pagination are already expressed as computed() signals, plugging in virtual scrolling doesn't require restructuring the component — it's an additive layer on top of pagedRows().
Accessibility Considerations
A generic table component is also the right place to centralize accessibility, since it's the one place every feature table inherits from. At minimum:
-
role="table",role="row", androle="columnheader"on the right elements when not using a native<table> -
aria-sorton sortable headers, kept in sync withsortKey()andsortDirection() - Keyboard support for triggering sort and navigating cells
- Announcing sort and selection changes for screen readers via a live region
Centralizing this once means every feature team using the table gets it for free, instead of every team remembering to implement it independently.
Design System Integration
A reusable table should consume design tokens rather than hardcoding spacing, color, or typography. CSS custom properties — or your design system's token layer — let the table inherit theme changes such as light/dark mode, brand updates, or density changes, without component-level changes. The table owns structure and behavior; the design system owns appearance. Keeping that boundary clean is what lets a single DataTable<T> look correct inside ten different feature areas without ten sets of overrides.
Testing a Platform Versus Testing a Page
Testing a generic DataTable<T> is different from testing a feature-specific table. Instead of testing "does the customer table render correctly," you test the table's behavior against arbitrary configuration:
- Given a set of
ColumnDefs, does it render the right headers? - Given
sortable: true, does clicking a header togglesortKey()andsortDirection()correctly? - Given a
cellTemplate, does it project the template instead of the default renderer? - Given
serverSide: true, does it emitpageChangeinstead of paginating locally?
Each feature team then only needs to test their column configuration, not the table's internal sorting or pagination logic — because that's already covered once, centrally.
Common Pitfalls When Building Reusable Tables
A few ways this architecture goes wrong in practice, usually with good intentions:
Over-genericizing before there's a second use case. Building infinite configuration options before you actually have two real tables to generalize from produces speculative complexity nobody asked for. Wait for the second table before abstracting from the first.
Letting business logic leak into the table component. If DataTableComponent ends up importing a CustomerStatus enum or hardcoding a status-to-color mapping, the boundary between "platform" and "domain" has already broken down.
Treating cellTemplate as an escape hatch for everything. If every single column ends up needing a custom template, the column-definition layer isn't pulling its weight, and it's worth revisiting what belongs in the config versus the template.
Mixing client-side and server-side state without a clear boundary. Half-paginating in memory while also calling an API for "the next page" leads to subtle, hard-to-reproduce bugs. The serverSide flag should be the single source of truth for which mode a given table instance is in.
Forgetting trackBy. Without it, both virtual scrolling and OnPush lose most of their benefit, because Angular has no reliable way to tell rows apart across renders.
Putting visual concerns into column config. Pixel widths and CSS classes in a ColumnDef couple the table to one specific visual system and work against design-system integration. Keep column config structural; let templates and tokens handle appearance.
Putting It All Together
Here's a consolidated example showing the full component and a real consumer wired up end to end.
// customer.model.ts
export interface Customer {
id: string;
name: string;
email: string;
status: 'active' | 'invited' | 'suspended';
totals: { lifetime: number };
}
// data-table.component.ts
import { ChangeDetectionStrategy, Component, TemplateRef, computed, input, output, signal } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { ScrollingModule } from '@angular/cdk/scrolling';
export interface ColumnDef<T> {
key: keyof T | string;
header: string;
sortable?: boolean;
width?: string;
accessor?: (row: T) => unknown;
cellTemplate?: TemplateRef<{ $implicit: T; column: ColumnDef<T> }>;
}
@Component({
selector: 'app-data-table',
standalone: true,
imports: [NgTemplateOutlet, ScrollingModule],
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './data-table.component.html'
})
export class DataTableComponent<T> {
columns = input.required<ColumnDef<T>[]>();
rows = input.required<T[]>();
pageSize = input(25);
serverSide = input(false);
totalCount = input(0);
pageChange = output<{ page: number; pageSize: number; sortKey: string | null }>();
sortKey = signal<string | null>(null);
sortDirection = signal<'asc' | 'desc'>('asc');
currentPage = signal(0);
selectedIds = signal<Set<string>>(new Set());
sortedRows = computed(() => {
const key = this.sortKey();
if (!key || this.serverSide()) return this.rows();
return [...this.rows()].sort((a, b) =>
this.compare(a, b, key) * (this.sortDirection() === 'asc' ? 1 : -1)
);
});
pagedRows = computed(() => {
if (this.serverSide()) return this.rows();
const start = this.currentPage() * this.pageSize();
return this.sortedRows().slice(start, start + this.pageSize());
});
trackByFn = (row: T) => (row as any).id ?? row;
isSelected = (row: T) => this.selectedIds().has(this.getId(row));
toggleSelection(row: T) {
const next = new Set(this.selectedIds());
const id = this.getId(row);
next.has(id) ? next.delete(id) : next.add(id);
this.selectedIds.set(next);
}
toggleSort(key: string) {
if (this.sortKey() === key) {
this.sortDirection.set(this.sortDirection() === 'asc' ? 'desc' : 'asc');
} else {
this.sortKey.set(key);
this.sortDirection.set('asc');
}
this.emitPageChange();
}
onPageChange(page: number) {
this.currentPage.set(page);
this.emitPageChange();
}
resolveValue(row: T, col: ColumnDef<T>): unknown {
return col.accessor ? col.accessor(row) : (row as any)[col.key as string];
}
private emitPageChange() {
if (!this.serverSide()) return;
this.pageChange.emit({
page: this.currentPage(),
pageSize: this.pageSize(),
sortKey: this.sortKey()
});
}
private getId(row: T): string {
return (row as any).id ?? JSON.stringify(row);
}
private compare(a: T, b: T, key: string): number {
const va = this.resolveValue(a, { key } as ColumnDef<T>);
const vb = this.resolveValue(b, { key } as ColumnDef<T>);
return va === vb ? 0 : (va as any) > (vb as any) ? 1 : -1;
}
}
<!-- data-table.component.html -->
<table role="table">
<thead>
<tr role="row">
@for (col of columns(); track col.key) {
<th
role="columnheader"
[attr.aria-sort]="sortKey() === col.key ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : 'none'"
[style.width]="col.width"
(click)="col.sortable && toggleSort(col.key.toString())"
>
{{ col.header }}
@if (sortKey() === col.key) {
<span>{{ sortDirection() === 'asc' ? '▲' : '▼' }}</span>
}
</th>
}
</tr>
</thead>
<tbody>
@for (row of pagedRows(); track trackByFn(row)) {
<tr role="row">
@for (col of columns(); track col.key) {
<td role="cell">
@if (col.cellTemplate) {
<ng-container *ngTemplateOutlet="col.cellTemplate; context: { $implicit: row, column: col }"></ng-container>
} @else {
{{ resolveValue(row, col) }}
}
</td>
}
</tr>
}
</tbody>
</table>
<div class="pagination">
<button (click)="onPageChange(currentPage() - 1)" [disabled]="currentPage() === 0">Previous</button>
<span>Page {{ currentPage() + 1 }}</span>
<button (click)="onPageChange(currentPage() + 1)">Next</button>
</div>
And the consumer side — a feature team's customer list, with zero knowledge of sorting or pagination internals:
// customers-list.component.ts
@Component({
selector: 'app-customers-list',
standalone: true,
imports: [DataTableComponent],
templateUrl: './customers-list.component.html'
})
export class CustomersListComponent {
private customersService = inject(CustomersService);
customers = toSignal(this.customersService.getAll(), { initialValue: [] });
columns: ColumnDef<Customer>[] = [
{ key: 'name', header: 'Name', sortable: true },
{ key: 'email', header: 'Email' },
{ key: 'status', header: 'Status' },
{ key: 'lifetimeValue', header: 'LTV', sortable: true, accessor: c => c.totals.lifetime }
];
}
<!-- customers-list.component.html -->
<app-data-table [columns]="columns" [rows]="customers()"></app-data-table>
Swap Customer for Order or Invoice, write a new columns array, and the same DataTableComponent handles it — no new sorting logic, no new pagination logic, no new selection logic.
Migrating an Existing Table to This Architecture
Most teams don't get to build this from a blank repo — they have five existing tables already in production. A practical migration path:
- Audit first. List every table-like component in the codebase and note what's actually different between them. In most codebases, the differences are smaller than expected — usually just column count and cell formatting.
-
Extract column definitions without touching markup. Pull each table's columns into a
ColumnDef[]array as a first step, even before the generic component exists. This forces the real API surface into the open. -
Build the generic shell alongside the old tables, not instead of them. Land
DataTableComponentin the codebase and prove it against one low-risk table first. - Migrate one table at a time, validating with snapshot or visual regression tests before removing the old component. This keeps the change reviewable and reversible.
- Retire the duplicated logic last. Only delete the old sorting/pagination/selection code once every table that depended on it has moved to the generic component.
This order matters more than the individual steps — migrating column definitions before behavior, and behavior before deleting old code, keeps the team shipping throughout instead of freezing feature work for a rewrite.
Frequently Asked Questions
Should every table in my app use this pattern, even small internal ones?
Not necessarily. If a table is truly one-off and unlikely to be copied, a simple component is fine. The generic architecture pays for itself once you have — or expect — more than two or three similar tables.
How generic is too generic?
If you're adding configuration options for a use case that doesn't exist yet, that's a sign of over-genericizing. Generalize from real, current tables, not hypothetical future ones.
Can this be built on top of Angular Material's mat-table?
Yes — ColumnDef, cell templates, and Signals-based state can wrap mat-table and MatSort/MatPaginator instead of a custom <table>. The architectural principles (configuration over duplication, behavior as abstractions) stay the same regardless of which rendering layer sits underneath.
Does this work with NgRx or a signal-based store?
Yes. rows() can be backed by a computed() derived from store state instead of a plain input — the table doesn't need to know where its data comes from, only that it receives a signal of rows.
How do I handle column reordering or resizing?
Add order and width as mutable signals tied to each ColumnDef, persisted per user if needed. It's an extension of the same configuration-over-duplication principle — the table renders whatever order and width the config currently describes.
The Senior Mindset Shift
None of this is about writing fancier code. It's about answering a different question from the start. A page-shaped table answers one question well. A framework-shaped table answers a category of questions, indefinitely, without a rewrite.
The goal isn't to build one great table. The goal is to never build the same table twice.
Wrapping Up
A reusable Angular Data Table is, at its core, a platform decision disguised as a component decision: configuration over duplication, column definitions as the API surface, content projection for customization, and Signals for predictable, performant state.
If you're maintaining several "different" tables in your own codebase right now, it might be worth asking the same question this post opened with: could your current table support a business domain it wasn't built for — or would it mean starting from scratch?
I write about Angular architecture, enterprise UI patterns, and frontend best practices at Programming Mastery Academy — follow along for more breakdowns like this one.
📌 More From Me
I share daily insights on web development, architecture, and frontend ecosystems.
Follow me here on Dev.to, and connect on LinkedIn for professional discussions.
🌐 Connect With Me
If you enjoyed this post and want more insights on scalable frontend systems, follow my work across platforms:
🔗 LinkedIn — Professional discussions, architecture breakdowns, and engineering insights.
📸 Instagram — Visuals, carousels, and design‑driven posts under the Terminal Elite aesthetic.
🧠 Website — Articles, tutorials, and project showcases.
🎥 YouTube — Deep‑dive videos and live coding sessions.