Choosing MatTableDataSource vs. Custom DataSource for Angular Material Tables
Decide quickly whether to use Angular Material’s MatTableDataSource for client‑side pagination and sorting or to implement a custom DataSource for server‑side paging. The guide lists constraints, compares the two options, explains trade‑offs, and shows concrete code for both approaches.
05 Oct 2026, 13:03 UTC

Decision Statement
The core engineering question is: Where should pagination, sorting, and filtering logic live? For small, static datasets (<1 000 rows) the built‑in MatTableDataSource is usually the fastest to ship. For large or dynamic datasets, a custom DataSource that fetches a page from the server is the safer choice. The decision hinges on three constraints:
- Dataset size – how many rows can fit comfortably in browser memory?
- Latency tolerance – is a brief round‑trip to the server acceptable after each sort or page change?
- Feature complexity – do you need custom filtering, nested property sorting, or virtual scrolling?
Compact Comparison Table
| Feature | MatTableDataSource (Client‑Side) | Custom DataSource (Server‑Side) |
|---|---|---|
| Initial data load | Full array fetched once | Single page fetched per action |
| Sorting | Automatic with MatSort |
Manual: send sort params to backend |
| Pagination | Handled by MatPaginator via dataSource.paginator |
Manual: update data on (page) event |
| Filtering | Built‑in filter property (linear scan) |
Custom: send filter string to backend |
| Memory footprint | O(n) – entire array in memory | O(pageSize) – only current slice |
| Complexity | Low – declarative | High – event wiring + custom service |
| Extensibility | Limited – relies on default data accessor | Full control – can implement virtual scrolling, nested sorting, etc. |
Trade‑Offs
- Performance vs. Development Speed: Client‑side is instant but can become sluggish with large arrays. Server‑side adds latency but scales.
- Memory vs. Latency: Keeping all rows in memory increases RAM usage; fetching per page keeps memory low at the cost of network round‑trips.
- Feature Flexibility: Custom data sources let you implement advanced filtering, nested property sorting, or virtual scrolling that the built‑in wrapper cannot handle without workarounds.
- Edge Cases: Zero rows, dynamic page size changes, and nested property columns require extra handling in both approaches.
Client‑Side Implementation with MatTableDataSource
Assumptions: Angular 16+, standalone component, BrowserAnimationsModule imported in the root module.
import { Component, ViewChild, AfterViewInit } from '@angular/core';
import { MatTableModule } from '@angular/material/table';
import { MatPaginatorModule, MatPaginator } from '@angular/material/paginator';
import { MatSortModule, MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
@Component({
selector: 'app-user-table',
standalone: true,
imports: [MatTableModule, MatPaginatorModule, MatSortModule],
templateUrl: './user-table.component.html'
})
export class UserTableComponent implements AfterViewInit {
displayedColumns = ['id', 'name', 'email'];
dataSource = new MatTableDataSource(mockUsers());
@ViewChild(MatPaginator) paginator!: MatPaginator;
@ViewChild(MatSort) sort!: MatSort;
ngAfterViewInit(): void {
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
}
/** Override for nested property sorting */
ngOnInit(): void {
this.dataSource.sortingDataAccessor = (item, property) => {
const value = property.split('.').reduce((obj, key) => obj?.[key], item);
return typeof value === 'string' ? value.toLowerCase() : value;
};
}
}
function mockUsers(): User[] {
const users: User[] = [];
for (let i = 1; i <= 100; i++) {
users.push({ id: i, name: `User ${i}`, email: `user${i}@example.com` });
}
return users;
}
interface User { id: number; name: string; email: string; }
Template (user-table.component.html):
<table mat-table [dataSource]="dataSource" class="mat-elevation-z8">
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef mat-sort-header>ID</th>
<td mat-cell *matCellDef="let user">{{user.id}}</td>
</ng-container>
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Name</th>
<td mat-cell *matCellDef="let user">{{user.name}}</td>
</ng-container>
<ng-container matColumnDef="email">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Email</th>
<td mat-cell *matCellDef="let user">{{user.email}}</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns; trackBy: trackById"></tr>
</table>
<mat-paginator [pageSizeOptions]="[5,10,25,50]" showFirstLastButtons></mat-paginator>
TrackBy function (in the component):
trackById(index: number, user: User): number {
return user.id;
}
Verification steps:
- Open the page, confirm 10 rows per default page.
- Click the Name header – rows reorder alphabetically.
- Change page size to 5 – only 5 rows are rendered, but the paginator shows total 100.
- Open DevTools Network tab – no additional requests should appear on pagination or sorting.
Server‑Side Implementation with a Custom DataSource
When the dataset exceeds a few thousand rows, load only the current page from the backend.
import { DataSource } from '@angular/cdk/collections';
import { MatPaginator, PageEvent } from '@angular/material/paginator';
import { MatSort, Sort } from '@angular/material/sort';
import { Observable, BehaviorSubject, combineLatest } from 'rxjs';
import { map, tap } from 'rxjs/operators';
export interface User { id: number; name: string; email: string; }
export interface PageResult {
data: T[];
total: number;
}
export class UserDataSource extends DataSource {
private data$ = new BehaviorSubject([]);
private total$ = new BehaviorSubject(0);
constructor(
private service: UserService,
private paginator: MatPaginator,
private sort: MatSort
) { super(); }
connect(): Observable {
return combineLatest([
this.data$, this.paginator.page, this.sort.sortChange
]).pipe(
tap(() => this.loadPage()),
map(() => this.data$.value)
);
}
disconnect() {
this.data$.complete();
this.total$.complete();
}
private loadPage(): void {
const params = {
pageIndex: this.paginator.pageIndex,
pageSize: this.paginator.pageSize,
sortField: this.sort.active,
sortDirection: this.sort.direction
};
this.service.query(params).subscribe((res: PageResult) => {
this.data$.next(res.data);
this.total$.next(res.total);
this.paginator.length = res.total;
});
}
}
Service stub (UserService):
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class UserService {
constructor(private http: HttpClient) {}
query(params: { pageIndex: number; pageSize: number; sortField: string; sortDirection: string }): Observable<PageResult<User>> {
const httpParams = new HttpParams({
fromObject: {
page: params.pageIndex.toString(),
size: params.pageSize.toString(),
sort: `${params.sortField},${params.sortDirection}`
}
});
return this.http.get<PageResult<User>>('/api/users', { params: httpParams });
}
}
Component usage:
@Component({
selector: 'app-user-table',
standalone: true,
imports: [MatTableModule, MatPaginatorModule, MatSortModule],
templateUrl: './user-table.component.html'
})
export class UserTableComponent implements AfterViewInit {
displayedColumns = ['id', 'name', 'email'];
dataSource!: UserDataSource;
@ViewChild(MatPaginator) paginator!: MatPaginator;
@ViewChild(MatSort) sort!: MatSort;
constructor(private userService: UserService) {}
ngAfterViewInit(): void {
this.dataSource = new UserDataSource(this.userService, this.paginator, this.sort);
}
}
Template (same as client‑side but without trackBy needed because only a page is rendered):
<table mat-table [dataSource]="dataSource" class="mat-elevation-z8">
</table>
<mat-paginator [pageSizeOptions]="[5,10,25,50]" showFirstLastButtons></mat-paginator>
Verification:
- Open the page – the first page of 10 users loads via an HTTP GET.
- Navigate to page 2 – a second GET request is issued; the network tab shows a new request.
- Click a column header – the sort order changes and a new request is sent with
sortquery param. - Check the
paginator.lengthvalue matches thetotalreturned by the API.
Edge Cases & Practical Checks
- Zero rows: MatPaginator hides itself automatically when
lengthis 0. - Dynamic page size: Changing
pageSizeOptionsupdates the paginator; ensure the backend accepts the new size. - Nested sorting: In the client‑side example,
sortingDataAccessoris overridden to supportuser.profile.name. In the server‑side case, the backend must expose the same nested field in thesortquery. - Filtering: Client‑side
dataSource.filterperforms a linear scan; for large datasets, implement afilterPredicatethat usesincludesor a regex. Server‑side filtering should be delegated to the API via asearchquery param. - Memory check: In the client‑side mode, open DevTools Memory tab and take a snapshot before and after loading 10 000 rows to see the heap increase.
Conclusion
Choose MatTableDataSource when:
- The dataset is <1 000 rows or can be cached safely.
- Development speed and minimal code are priorities.
- You need quick, instant UI updates without network latency.
Choose a custom DataSource when:
- Data originates from a backend that can filter, sort, and paginate.
- Memory usage must stay bounded, or you anticipate >10 000 rows.
- Advanced features (virtual scrolling, complex filtering) are required.
Both approaches are supported by Angular Material. The key is to match the dataset characteristics and feature needs to the appropriate strategy, and to validate the implementation by inspecting the network traffic and memory usage as outlined above.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.