We are going to cover many of the most common use cases that revolve around the Angular Material Data Table component, such as: server-side pagination, sorting, and filtering.
This is a step-by-step tutorial, so I invite you to code along as we are going to start with a simple initial scenario. We will then progressively add features one by one and explain everything along the way (including gotchas).
We will learn in detail all about the reactive design principles involved in the design of the Angular Material Data Table and an Angular CDK Data Source.
The end result of this post will be:
- a complete example of how to implement an Angular Material Data Table with server-side pagination, sorting and filtering using a custom CDK Data Source
- a running example available on Github, which includes a small backend Express server that serves the paginated data
Table Of Contents
In this post, we will cover the following topics:- The Angular Material Data Table - not only for Material Design
- The Material Data Table Reactive Design
- The Material Paginator and Server-side Pagination
- Sortable Headers and Server-side Sorting
- Server-side Filtering with Material Input Box
- A Loading Indicator
- A Custom Angular Material CDK Data Source
- Source Code (on Github) with the complete example
- Conclusions
Importing Angular Material modules
In order to run our example, let's first import all the Angular Material modules that we will need:import { MatInputModule, MatPaginatorModule, MatProgressSpinnerModule, | |
MatSortModule, MatTableModule } from "@angular/material"; | |
@NgModule({ | |
declarations: [ | |
... | |
], | |
imports: [ | |
BrowserModule, | |
BrowserAnimationsModule, | |
HttpClientModule, | |
MatInputModule, | |
MatTableModule, | |
MatPaginatorModule, | |
MatSortModule, | |
MatProgressSpinnerModule | |
], | |
providers: [ | |
... | |
], | |
bootstrap: [AppComponent] | |
}) | |
export class AppModule { | |
} | |
- MatInputModule: this contains the components and directives for adding Material design Input Boxes to our application (needed for the search input box)
- MatTableModule: this is the core data table module, which includes the
mat-table
component and many related components and directives - MatPaginatorModule: this is a generic pagination module, that can be used to paginate data in general. This module can also be used separately from the Data table, for example for implementing Detail pagination logic in a Master-Detail setup
- MatSortModule: this is an optional module that allows adding sortable headers to a data table
- MatProgressSpinnerModule: this module includes the progress indicator component that we will be using to indicate that data is being loaded from the backend
Introduction to the Angular Material Data Table
The Material Data Table component is a generic component for displaying tabulated data. Although we can easily give it a Material Design look and feel, this is actually not mandatory.In fact, we can give the Angular Material Data table an alternative UI design if needed. To see that this is so, let's start by creating a Data Table where the table cells are just plain divs with no custom CSS applied.
This data table will display a list of course lessons, and has 3 columns (sequence number, description and duration):
<mat-table class="lessons-table mat-elevation-z8" [dataSource]="dataSource"> | |
<ng-container matColumnDef="seqNo"> | |
<div *matHeaderCellDef>#</div> | |
<div *matCellDef="let lesson">{{lesson.seqNo}}</div> | |
</ng-container> | |
<ng-container matColumnDef="description"> | |
<div *matHeaderCellDef>Description</div> | |
<div class="description-cell" | |
*matCellDef="let lesson">{{lesson.description}}</div> | |
</ng-container> | |
<ng-container matColumnDef="duration"> | |
<div *matHeaderCellDef>Duration</div> | |
<div class="duration-cell" | |
*matCellDef="let lesson">{{lesson.duration}}</div> | |
</ng-container> | |
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row> | |
<mat-row *matRowDef="let row; columns: displayedColumns"></mat-row> | |
</mat-table> | |
Material Data Table Column Definitions
As we can see, this table defines 3 columns, each inside its ownng-container
element. The ng-container
element will NOT be rendered to the screen (see this post for more details), but it will provide an element for applying the matColumnDef
directive.The
matColumnDef
directive uniquely identifies a given column with a key: seqNo, description or duration. Inside the ng-container
element, we will have all the configuration for a given column.
Notice that the order of the ng-container
elements does NOT determine the column visual order
The Material Data Table Auxiliary Definition Directives
The Material Data Table has a series of auxiliary structural directives (applied using the*directiveName
syntax) that allow us to mark certain template sections has having a certain role in the overall data table design.These directives always end with the
Def
postfix, and they are used to assign a role to a template section. The first two directives that we will cover are matHeaderCellDef
and matCellDef
.
The matHeaderCellDef
and matCellDef
Directives
Inside of each ng-container
with a given column definition, there are a couple of configuration elements:- we have the template that defines how to display the header of a given column, identified via the
matHeaderCellDef
structural directive - we also have another template that defines how to display the data cells of a given column, using the
matCellDef
structural directive
For example, in this case,
matCellDef
and matHeaderCellDef
are being applied to plain divs with no styling, so this is why this table does not have a Material design yet.Applying a Material Design to the Data Table
Let's now see what it would take to give this Data Table a Material Look and Feel. For that, we will use a couple of built-in components in our header and cell template definitions:<mat-table class="lessons-table mat-elevation-z8" [dataSource]="dataSource"> | |
<ng-container matColumnDef="seqNo"> | |
<mat-header-cell *matHeaderCellDef>#</mat-header-cell> | |
<mat-cell *matCellDef="let lesson">{{lesson.seqNo}}</mat-cell> | |
</ng-container> | |
<ng-container matColumnDef="description"> | |
<mat-header-cell *matHeaderCellDef>Description</mat-header-cell> | |
<mat-cell class="description-cell" | |
*matCellDef="let lesson">{{lesson.description}}</mat-cell> | |
</ng-container> | |
<ng-container matColumnDef="duration"> | |
<mat-header-cell *matHeaderCellDef>Duration</mat-header-cell> | |
<mat-cell class="duration-cell" | |
*matCellDef="let lesson">{{lesson.duration}}</mat-cell> | |
</ng-container> | |
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row> | |
<mat-row *matRowDef="let row; columns: displayedColumns"></mat-row> | |
</mat-table> | |
mat-header-cell
and mat-cell
components inside our column definition instead of plain divs.Using these components, lets now have a look at what the Data Table looks like with this new Material Design:
Notice that the table already has some data! We will get to the data source in a moment, right now let's continue exploring the rest of the template.
The matCellDef
Directive
The data cell template has access to the data that is being displayed. In this case, our data table is displaying a list of lessons, so the lesson object in each row is accessible via the let lesson
syntax, and can be used in the template just like any component variable.
The mat-header-row
component and the matHeaderRowDef
directive
This combination of related component / directive works in the following way:- the
matHeaderRowDef
identifies a configuration element for the table header row, but it does not apply any styling to the element - The
mat-header-row
on the other hand applies some minimal Material stying
matHeaderRowDef
directive also defines in which order the columns should be displayed. In our case, the directive expression is pointing to a component variable named displayedColumns
.Here is what the
displayedColumns
component variable will look like:displayedColumns = ["seqNo", "description", "duration"]; | |
ng-container
column sections (specified via the matColumnDef
directive).Note: It's this array that determines the visual order of the columns!
The mat-row
component and the matRowDef
directive
This component / directive pair also works in a similar way to what we have seen in previous cases:matRowDef
identifies which element insidemat-table
provides configuration for how a data row should look like, without providing any specific styling- on the other hand,
mat-row
will provide some Material stying to the data row
mat-row
, we also have a variable exported that we have named row
, containing the data of a given data row, and we have to specify the columns
property, which contains the order on which the data cells should be defined.Interacting with a given table data row
We can even use the element identified by thematRowDef
directive to interact with a given data row. For example, this is how we can detect if a given data row was clicked:<mat-row *matRowDef="let row; columns: displayedColumns" | |
(click)="onRowClicked(row)"> | |
</mat-row> | |
onRowClicked()
component method, that will then log the row data to the console:onRowClicked(row) { | |
console.log('Row clicked: ', row); | |
} | |
As we can see the data for the first row is being printed to the console, as expected! But where is this data coming from?
To answer that, let's then talk about the data source that is linked to this data table, and go over the Material Data Table reactive design.
Data Sources and the Data Table Reactive Design
The data table that we have been presenting receives the data that it displays from a Data Source that implements an Observable-based API and follows common reactive design principles.This means for example that the data table component does not know where the data is coming from. The data could be coming for example from the backend, or from a client-side cache, but that is transparent to the Data table.
The Data table simply subscribes to an Observable provided by the Data Source. When that Observable emits a new value, it will contain a list of lessons that then get's displayed in the data table.
Data Table core design principles
With this Observable-based API, not only the Data table does not know where the data is coming from, but the data table also does not know what triggered the arrival of new data.Here are some possible causes for the emission of new data:
- the data table is initially displayed
- the user clicks on a paginator button
- the user sorts the data by clicking on a sortable header
- the user types a search using an input box
Let's then see how can we implement such a reactive data source.
Why not use MatTableDataSource
?
In this example, we will not be using the built-in MatTableDataSource
because its designed for filtering, sorting and pagination of a client-side data array.In our case, all the filtering, sorting and pagination will be happening on the server, so we will be building our own Angular CDK reactive data source from first principles.
Fetching Data from the backend
In order to fetch data from the backend, our custom Data Source is going to be using theLessonsService
. This is a standard Observable-based stateless singleton service that is built internally using the Angular HTTP Client.Let's have a look at this service, and break down how its implemented:
@Injectable() | |
export class CoursesService { | |
constructor(private http:HttpClient) {} | |
findLessons( | |
courseId:number, filter = '', sortOrder = 'asc', | |
pageNumber = 0, pageSize = 3): Observable<Lesson[]> { | |
return this.http.get('/api/lessons', { | |
params: new HttpParams() | |
.set('courseId', courseId.toString()) | |
.set('filter', filter) | |
.set('sortOrder', sortOrder) | |
.set('pageNumber', pageNumber.toString()) | |
.set('pageSize', pageSize.toString()) | |
}).pipe( | |
map(res => res["payload"]) | |
); | |
} | |
} | |
Breaking down the LessonsService
implementation
As we can see, this service is completely stateless, and every method forwards calls to the backend using the HTTP client, and returns an Observable to the caller.Our REST API is available in URLs under the
/api
directory, and multiple services are available (here is the complete implementation).In this snippet, we are just showing the
findLessons()
method, that allows to obtain one filtered and sorted page of lessons data for a given course.Here are the arguments that we can pass to this function:
- courseId: This identifies a given course, for which we want to retrieve a page of lessons
- filter: This is a search string that will help us filter the results. If we pass the empty string '' it means that no filtering is done on the server
- sortOrder: our backend allows us to sort based on the
seqNo
column, and with this parameter, we can specify is the sort order is ascending (which is the defaultasc
value), or descending by passing the valuedesc
- pageNumber: With the results filtered and sorted, we are going to specify which page of that full list of results we need. The default is to return the first page (with index 0)
- pageSize: this specifies the page size, which defaults to a maximum of 3 elements
loadLessons()
method will then build an HTTP GET call to the backend endpoint available at /api/lessons
.Here is what an HTTP GET call that fetches the lessons for the first page looks like:
http://localhost:4200/api/lessons?courseId=1&filter=&sortOrder=asc&pageNumber=0&pageSize=3
As we can see, we are appending a series of HTTP query parameters to the GET URL using the HTTPParams
fluent API.This
loadLessons()
method will be the basis of our Data Source, as it will allow us to cover the server pagination, sorting and filtering use cases.Implementing a Custom Angular CDK Data Source
Using theLessonsService
, let's now implement a custom Observable-based Angular CDK Data Source. Here is some initial code, so that we can discuss its Reactive design (the full version is shown in a moment):import {CollectionViewer, DataSource} from "@angular/cdk/collections"; | |
export class LessonsDataSource implements DataSource<Lesson> { | |
private lessonsSubject = new BehaviorSubject<Lesson[]>([]); | |
constructor(private coursesService: CoursesService) {} | |
connect(collectionViewer: CollectionViewer): Observable<Lesson[]> { | |
... | |
} | |
disconnect(collectionViewer: CollectionViewer): void { | |
... | |
} | |
loadLessons(courseId: number, filter: string, | |
sortDirection: string, pageIndex: number, pageSize: number) { | |
... | |
} | |
} | |
Breaking down the design of an Angular CDK Data Source
Has we can see, in order to create a Data Source we need to create a class that implementsDataSource
. This means that this class needs to implement a couple of methods: connect()
and disconnect()
.Note that these methods provide an argument which is a
CollectionViewer
, which provides an Observable that emits information about what data is being displayed (the start index and the end index).We would recommend for now not to focus so much on the
CollectionViewer
at this moment, but on something much more important for understanding the whole design: the return value of the connect()
method.
How to implement the DataSource connect()
method
This method will be called once by the Data Table at table bootstrap time. The Data Table expects this method to return an Observable, and the values of that observable contain the data that the Data Table needs to display.In this case, this observable will emit a list of Lessons. As the user clicks on the paginator and changes to a new page, this observable will emit a new value with the new lessons page.
We will implement this method by using a subject that is going to be invisible outside this class. That subject (the
lessonsSubject
) is going to be emitting the values retrieved from the backend.The
lessonsSubject
is a BehaviorSubject
, which means its subscribers will always get its latest emitted value (or an initial value), even if they subscribed late (after the value was emitted).
Why use BehaviorSubject
?
Using BehaviorSubject
is a great way of writing code that works independently of the order that we use to perform asynchronous operations such as: calling the backend, binding the data table to the data source, etc.For example, in this design, the Data Source is not aware of the data table or at which moment the Data Table will require the data. Because the data table subscribed to the
connect()
observable, it will eventually get the data, even if:- the data is still in transit coming from the HTTP backend
- or if the data was already loaded
Custom Material CDK Data Source - Full Implementation Review
Now that we understand the reactive design of the data source, let's have a look at the complete final implementation and review it step-by-step.Notice that in this final implementation, we have also included the notion of a loading flag, that we will use to display a spinning loading indicator to the user later on:
export class LessonsDataSource implements DataSource<Lesson> { | |
private lessonsSubject = new BehaviorSubject<Lesson[]>([]); | |
private loadingSubject = new BehaviorSubject<boolean>(false); | |
public loading$ = this.loadingSubject.asObservable(); | |
constructor(private coursesService: CoursesService) {} | |
connect(collectionViewer: CollectionViewer): Observable<Lesson[]> { | |
return this.lessonsSubject.asObservable(); | |
} | |
disconnect(collectionViewer: CollectionViewer): void { | |
this.lessonsSubject.complete(); | |
this.loadingSubject.complete(); | |
} | |
loadLessons(courseId: number, filter = '', | |
sortDirection = 'asc', pageIndex = 0, pageSize = 3) { | |
this.loadingSubject.next(true); | |
this.coursesService.findLessons(courseId, filter, sortDirection, | |
pageIndex, pageSize).pipe( | |
catchError(() => of([])), | |
finalize(() => this.loadingSubject.next(false)) | |
) | |
.subscribe(lessons => this.lessonsSubject.next(lessons)); | |
} | |
} | |
Data Source Loading Indicator Implementation Breakdown
Let's start breaking down this code, we will start first with the implementation of the loading indicator. Because this Data Source class has a reactive design, let's implement the loading flag by exposing a boolean observable calledloading$
.This observable will emit as first value
false
(which is defined in the BehaviorSubject
constructor), meaning that no data is loading initially.The
loading$
observable is derived using asObservable()
from a subject that is kept private to the data source class. The idea is that only this class knows when data is loading, so only this class can access the subject and emit new values for the loading flag.
The connect()
method implementation
Let's now focus on the implementation of the connect method:connect(collectionViewer: CollectionViewer): Observable<Lesson[]> { | |
return this.lessonsSubject.asObservable(); | |
} | |
lessonsSubject
directly.Exposing the subject would mean yielding control of when and what data gets emitted by the data source, and we want to avoid that. We want to ensure that only this class can emit values for the lessons data.
So we are also going to return an Observable derived from
lessonsSubject
using the asObservable()
method. This gives the data table (or any other subscriber) the ability to subscribe to the lessons data observable, without being able to emit values for that same observable.
The disconnect()
method implementation
Let's now break down the implementation of the disconnect method:disconnect(collectionViewer: CollectionViewer): void { | |
this.lessonsSubject.complete(); | |
this.loadingSubject.complete(); | |
} | |
We are going to complete both the
lessonsSubject
and the loadingSubject
, which are then going to trigger the completion of any derived observables.
The loadLessons()
method implementation
Finally, let's now focus on the implementation of the loadLessons method:loadLessons(courseId: number, filter = '', | |
sortDirection = 'asc', pageIndex = 0, pageSize = 3) { | |
this.loadingSubject.next(true); | |
this.coursesService.findLessons(courseId, filter, sortDirection, | |
pageIndex, pageSize).pipe( | |
catchError(() => of([])), | |
finalize(() => this.loadingSubject.next(false)) | |
) | |
.subscribe(lessons => this.lessonsSubject.next(lessons)); | |
} | |
loadLessons()
. This method is going to be called in response to multiple user actions (pagination, sorting, filtering) to load a given data page.Here is how this method works:
- the first thing that we will do is to report that some data is being loaded, by emitting
true
to theloadingSubject
, which will causeloading$
to also emit true - the
LessonsService
is going to be used to get a data page from the REST backend - a call to
findLessons()
is made, that returns an Observable - by subscribing to that observable, we trigger an HTTP request
- if the data arrives successfully from the backend, we are going to emit it back to the data table, via the
connect()
Observable - for that, we will call
next()
on thelessonsSubject
with the lessons data - the derived lessons observable returned by
connect()
will then emit the lessons data to the data table
Handling Backend Errors
Let's now see, still in theloadLessons()
method, how the Data Source handles backend errors, and how the loading indicator is managed:- if an error in the HTTP request occurs, the Observable returned by
findLessons()
will error out - If that occurs, we are going to catch that error using
catchError()
and we are going to return an Observable that emits the empty array usingof
- we could complementary also use another
MessagesService
to show a closable error popup to the user - wether the call to the backend succeeds or fails, we will in both cases have the
loading$
Observable emitfalse
by usingfinalize()
(which works likefinally
in plain Javascript try/catch/finally)
This version of the data source will support all our use cases: pagination, sorting and filtering. As we can see, the design is all about providing data transparently to the Data Table using an Observable-based API.
Let's now see how we can take this Data Source and plug it into the Data Table.
Linking a Data Source with the Data Table
The Data Table will be displayed as part of the template of a component. Let's write an initial version of that component, that displays the first page of lessons:@Component({ | |
selector: 'course', | |
templateUrl: './course.component.html', | |
styleUrls: ['./course.component.css'] | |
}) | |
export class CourseComponent implements OnInit { | |
dataSource: LessonsDataSource; | |
displayedColumns= ["seqNo", "description", "duration"]; | |
constructor(private coursesService: CoursesService) {} | |
ngOnInit() { | |
this.dataSource = new LessonsDataSource(this.coursesService); | |
this.dataSource.loadLessons(1); | |
} | |
} | |
- the
displayedColumns
array defines the visual order of the columns - The
dataSource
property defines an instance ofLessonsDataSource
, and that is being passed tomat-table
via the template
Breaking down the ngOnInit
method
In the ngOnInit
method, we are calling the Data Source loadLessons()
method to trigger the loading of the first lessons page. Let's detail what happens as a result of that call:- The Data Source calls the
LessonsService
, which triggers an HTTP request to fetch the data - The Data Source then emits the data via the
lessonsSubject
, which causes the Observable returned byconnect()
to emit the lessons page - The
mat-table
Data Table component has subscribed to theconnect()
observable and retrieves the new lessons page - The Data Table then displays the new lessons page, without knowing where the data came from or what triggered its arrival
The problem is that this initial example is always loading only the first page of data, with a page size of 3 and with no search criteria.
Let's use this example as a starting point, and starting adding: a loading indicator, pagination, sorting, and filtering.
Displaying a Material Loading Indicator
In order to display the loading indicator, we are going to be using theloading$
observable of the Data Source. We will be using the mat-spinner
Material component:<div class="course"> | |
<div class="spinner-container" *ngIf="dataSource.loading$ | async"> | |
<mat-spinner></mat-spinner> | |
</div> | |
<mat-table class="lessons-table mat-elevation-z8" [dataSource]="dataSource"> | |
.... | |
</mat-table> | |
</div> | |
async
pipe and ngIf
to show or hide the material loading indicator. Here is what the table looks like while the data is loading:We will also be using the loading indicator when transitioning between two data pages using pagination, sorting or filtering.
Adding a Data Table Material Paginator
The Material Paginator component that we will be using is a generic paginator that comes with an Observable-based API. This paginator could be used to paginate anything, and it's not specifically linked to the Data Table.For example, on a Master-Detail component setup, we could use this paginator to navigate between two detail elements.
This is how the
mat-paginator
component can be used in a template:<div class="course"> | |
<div class="spinner-container" *ngIf="dataSource.loading$ | async"> | |
<mat-spinner></mat-spinner> | |
</div> | |
<mat-table class="lessons-table mat-elevation-z8" [dataSource]="dataSource"> | |
.... | |
</mat-table> | |
<mat-paginator [length]="course?.lessonsCount" [pageSize]="3" | |
[pageSizeOptions]="[3, 5, 10]"></mat-paginator> | |
</div> |
CourseComponent
.The paginator only needs to know how many total items are being paginated (via the length property), in order to know how many total pages there are!
Its based on that information (plus the current page index) that the paginator will enable or disable the navigation buttons.
In order to pass that information to the paginator, we are using the
lessonsCount
property of a new course
object.How to Link the Material Paginator to the Data Source
Let's now have a look at theCourseComponent
, to see where course
is coming from and how the paginator is linked to the Data Source:@Component({ | |
selector: 'course', | |
templateUrl: './course.component.html', | |
styleUrls: ['./course.component.css'] | |
}) | |
export class CourseComponent implements AfterViewInit, OnInit { | |
course:Course; | |
dataSource: LessonsDataSource; | |
displayedColumns= ["seqNo", "description", "duration"]; | |
@ViewChild(MatPaginator) paginator: MatPaginator; | |
constructor(private coursesService: CoursesService, private route: ActivatedRoute) {} | |
ngOnInit() { | |
this.course = this.route.snapshot.data["course"]; | |
this.dataSource = new LessonsDataSource(this.coursesService); | |
this.dataSource.loadLessons(this.course.id, '', 'asc', 0, 3); | |
} | |
ngAfterViewInit() { | |
this.paginator.page | |
.pipe( | |
tap(() => this.loadLessonsPage()) | |
) | |
.subscribe(); | |
} | |
loadLessonsPage() { | |
this.dataSource.loadLessons( | |
this.course.id, | |
'', | |
'asc', | |
this.paginator.pageIndex, | |
this.paginator.pageSize); | |
} | |
} | |
Breaking down the ngOnInit()
method
Let's start with the course
object: as we can see this object is available at component construction time via the router.This data object was retrieved from the backend at router navigation time using a router Data Resolver (see an example here).
This is a very common design, that ensures that the target navigation screen already has some pre-fetched data ready to display.
We are also loading the first page of data directly in this method (on line 20).
How is the Paginator linked to the Data Source?
We can see in the code above that the link between the paginator and the Data Source is done in thengAfterViewInit()
method, so let's break it down:ngAfterViewInit() { | |
this.paginator.page | |
.pipe( | |
tap(() => this.loadLessonsPage()) | |
) | |
.subscribe(); | |
} | |
AfterViewInit
lifecycle hook because we need to make sure that the paginator component queried via @ViewChild
is already available.The paginator also has an Observable-based API, and it exposes a
page
Observable. This observable will emit a new value every time that the user clicks on the paginator navigation buttons or the page size dropdown.So in order to load new pages in response to a pagination event, all we have to do is to subscribe to this observable, and in response to a pagination event, we are going to make a call to the Data Source
loadLessons()
method, by calling loadLessonsPage()
.In that call to
loadLessons()
, we are going to pass to the Data Source what page index we would like to load, and what page size, and that information is taken directly from the paginator.
Why have we used the tap()
operator?
We could also have done the call to the data source from inside a subscribe()
handler, but in this case, we have implemented that call using the pipeable version of the RxJs do
operator called tap
.View the Paginator in Action
And with this in place, we now have a working Material Paginator! Here is what the Material Paginator looks like on the screen, while displaying page 2 of the lessons list:Let's now continue to add more features to our example, let's add another very commonly needed feature: sortable table headers.
Adding Sortable Material Headers
In order to add sortable headers to our Data Table, we will need to annotate it with thematSort
directive. In this case, we will make only one column in the table sortable, the seqNo
column.Here is what the template with all the multiple sort-related directives looks like:
<mat-table class="lessons-table mat-elevation-z8" [dataSource]="dataSource" | |
matSort matSortActive="seqNo" matSortDirection="asc" matSortDisableClear> | |
<ng-container matColumnDef="seqNo"> | |
<mat-header-cell *matHeaderCellDef mat-sort-header>#</mat-header-cell> | |
<mat-cell *matCellDef="let lesson">{{lesson.seqNo}}</mat-cell> | |
</ng-container> | |
.... | |
</mat-table> | |
matSort
directive, we are also adding a couple of extra auxiliary sort-related directives to the mat-table
component:- matSortActive: When the data is passed to the Data Table, its usually already sorted. This directive allows us to inform the Data Table that the data is already initally sorted by the
seqNo
column, so theseqNo
column sorting icon will be displayed as an upwards arrow - matSortDirection: This is a companion directive to
matSortActive
, it specifies the direction of the initial sort. In this case, the data is initially sorted by theseqNo
column in ascending order, and so the column header will adapt the sorting icon accordingly (screenshot below) - matSortDisableClear: Sometimes, besides ascending and descending order we might want a third "unsorted" state for the sortable column header, where we can clear the sorting order. In this case, we want to disable that to make sure the
seqNo
column always shown either the ascending or descending states
In our case, only the
seqNo
column is sortable, so we are annotating the column header cell with the mat-sort-header
directive.And this covers the template changes, let's now have a look at the changes we made to the
CourseComponent
in order to enable table header sorting.Linking the Sortable column header to the Data Source
Just like the case of pagination, the sortable header will expose an Observable that emits values whenever the user clicks on the sortable column header.The
MatSort
directive then exposes a sort Observable, that can trigger a new page load in the following way:@Component({ | |
selector: 'course', | |
templateUrl: './course.component.html', | |
styleUrls: ['./course.component.css'] | |
}) | |
export class CourseComponent implements AfterViewInit, OnInit { | |
course:Course; | |
dataSource: LessonsDataSource; | |
displayedColumns= ["seqNo", "description", "duration"]; | |
@ViewChild(MatPaginator) paginator: MatPaginator; | |
@ViewChild(MatSort) sort: MatSort; | |
constructor(private coursesService: CoursesService, private route: ActivatedRoute) {} | |
ngOnInit() { | |
this.course = this.route.snapshot.data["course"]; | |
this.dataSource = new LessonsDataSource(this.coursesService); | |
this.dataSource.loadLessons(this.course.id, '', 'asc', 0, 3); | |
} | |
ngAfterViewInit() { | |
// reset the paginator after sorting | |
this.sort.sortChange.subscribe(() => this.paginator.pageIndex = 0); | |
merge(this.sort.sortChange, this.paginator.page) | |
.pipe( | |
tap(() => this.loadLessonsPage()) | |
) | |
.subscribe(); | |
} | |
loadLessonsPage() { | |
this.dataSource.loadLessons( | |
this.course.id, '', this.sort.direction, | |
this.paginator.pageIndex, this.paginator.pageSize); | |
} | |
} | |
- when a pagination event occurs
- when a sort event occurs
seqNo
column is now taken from the sort directive (injected via @ViewChild()
) to the backend.Notice that after each sort we are also resetting the paginator, by forcing the first page of the sorted data to be displayed.
The Material Sort Header In Action
Here is what the Data Table with sortable headers looks like, after loading the data and clicking the sortable header (triggering a descending sort byseqNo
):
Notice the sort icon on the seqNo
column
At this point, we have server pagination and sorting in place. We are now ready to add the final major feature: server-side filtering.Adding Server-Side Filtering
In order to implement server-side filtering, the first thing that we need to do is to add a search box to our template.And because this is the final version, let's then display the complete template with all its features: pagination, sorting and also server-side filtering:
<div class="course"> | |
<!-- New part: this is the search box --> | |
<mat-input-container> | |
<input matInput placeholder="Search lessons" #input> | |
</mat-input-container> | |
<div class="spinner-container" *ngIf="dataSource.loading$ | async"> | |
<mat-spinner></mat-spinner> | |
</div> | |
<mat-table class="lessons-table mat-elevation-z8" [dataSource]="dataSource" | |
matSort matSortActive="seqNo" matSortDirection="asc" matSortDisableClear> | |
<ng-container matColumnDef="seqNo"> | |
<mat-header-cell *matHeaderCellDef mat-sort-header>#</mat-header-cell> | |
<mat-cell *matCellDef="let lesson">{{lesson.seqNo}}</mat-cell> | |
</ng-container> | |
<ng-container matColumnDef="description"> | |
<mat-header-cell *matHeaderCellDef>Description</mat-header-cell> | |
<mat-cell class="description-cell" | |
*matCellDef="let lesson">{{lesson.description}}</mat-cell> | |
</ng-container> | |
<ng-container matColumnDef="duration"> | |
<mat-header-cell *matHeaderCellDef>Duration</mat-header-cell> | |
<mat-cell class="duration-cell" | |
*matCellDef="let lesson">{{lesson.duration}}</mat-cell> | |
</ng-container> | |
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row> | |
<mat-row *matRowDef="let row; columns: displayedColumns"></mat-row> | |
</mat-table> | |
<mat-paginator [length]="course?.lessonsCount" [pageSize]="3" | |
[pageSizeOptions]="[3, 5, 10]"></mat-paginator> | |
</div> | |
Breaking down the Search Box implementation
As we can see, the only new part in this final template version is themat-input-container
, containing the Material Input box where the user types the search query.This input box follows a common pattern found in the Material library: The
mat-input-container
is wrapping a plain HTML input and projecting it.This gives us full access to all standard input properties including for example all the Accessibility-related properties. This also gives us compatibility with Angular Forms, as we can apply Form directives directly in the input HTML element.
Read more about how to build a similar component in this post: Angular ng-content and Content Projection: The Complete Guide.
Notice that there is not even an event handler attached to this input box ! Let's then have a look at the component and see how this works.
Final Component with Server Pagination, Sorting and Filtering
This is the final version ofCourseComponent
with all features included:@Component({ | |
selector: 'course', | |
templateUrl: './course.component.html', | |
styleUrls: ['./course.component.css'] | |
}) | |
export class CourseComponent implements OnInit, AfterViewInit { | |
course:Course; | |
dataSource: LessonsDataSource; | |
displayedColumns= ["seqNo", "description", "duration"]; | |
@ViewChild(MatPaginator) paginator: MatPaginator; | |
@ViewChild(MatSort) sort: MatSort; | |
@ViewChild('input') input: ElementRef; | |
constructor( | |
private route: ActivatedRoute, | |
private coursesService: CoursesService) {} | |
ngOnInit() { | |
this.course = this.route.snapshot.data["course"]; | |
this.dataSource = new LessonsDataSource(this.coursesService); | |
this.dataSource.loadLessons(this.course.id, '', 'asc', 0, 3); | |
} | |
ngAfterViewInit() { | |
// server-side search | |
fromEvent(this.input.nativeElement,'keyup') | |
.pipe( | |
debounceTime(150), | |
distinctUntilChanged(), | |
tap(() => { | |
this.paginator.pageIndex = 0; | |
this.loadLessonsPage(); | |
}) | |
) | |
.subscribe(); | |
// reset the paginator after sorting | |
this.sort.sortChange.subscribe(() => this.paginator.pageIndex = 0); | |
// on sort or paginate events, load a new page | |
merge(this.sort.sortChange, this.paginator.page) | |
.pipe( | |
tap(() => this.loadLessonsPage()) | |
) | |
.subscribe(); | |
} | |
loadLessonsPage() { | |
this.dataSource.loadLessons( | |
this.course.id, | |
this.input.nativeElement.value, | |
this.sort.direction, | |
this.paginator.pageIndex, | |
this.paginator.pageSize); | |
} | |
} | |
Getting a reference to the Search Input Box
We can see that we have injected a DOM reference to the<input>
element using @ViewChild('input')
. Notice that this time around, the injection mechanism gave us a reference to a DOM element and not to a component.With that DOM reference, here is the part that triggers a server-side search when the user types in a new query:
// server-side search | |
fromEvent(this.input.nativeElement,'keyup') | |
.pipe( | |
debounceTime(150), | |
distinctUntilChanged(), | |
tap(() => { | |
this.paginator.pageIndex = 0; | |
this.loadLessonsPage(); | |
}) | |
) | |
.subscribe(); | |
fromEvent
.This Observable will emit a value every time that a new keyUp event occurs. To this Observable we will then apply a couple of operators:
debounceTime(150)
: The user can type quite quickly in the input box, and that could trigger a lot of server requests. With this operator, we are limiting the amount of server requests emitted to a maximum of one every 150ms.distinctUntilChanged()
: This operator will eliminate duplicate values
tap()
operator.Let's now have a look at the what the screen would look like if the user types the search term "hello":
And with this in place, we have completed our example! We now have a complete solution for how to implement an Angular Material Data Table with server-side pagination, sorting and filtering.
Let's now quickly summarize what we have learned.
Conclusions
The Data Table, the Data Source and related components are a good example of a reactive design that uses an Observable-based API. Let's highlight the key points of the design:- the Material Data Table expects to receive the data from the Data Source via an Observable
- The Data Source main role is to build and provide an Observable that emits new versions of the tabular data to the Data Table
- A component class like
CourseService
will then "glue" everything together
Source Code + Github Running Example
A running example of the complete code is available here on this branch on Github, and it includes a small backend Express server that serves the data and does the server-side sorting/pagination/filtering.I hope that this post helps with getting started with the Angular Material Data Table and that you enjoyed it!
If you have some questions or comments please let me know in the comments below and I will get back to you.
To get notified of upcoming posts on Angular Material and other Angular topics, I invite you to subscribe to our newsletter:
Other Angular Material posts:
Angular Material Dialog: A Complete ExampleVideo Lessons Available on YouTube
Have a look at the Angular University Youtube channel, we publish about 25% to a third of our video tutorials there, new videos are published all the time.
Subscribe to get new video tutorials: