Rendering takes time. That is especially the case if you have to load large amounts of data from your server and display them. Sometimes the server doesn’t handle pagination and returns the whole collection of data at once.
Of course, you don’t want to display 2000 elements for the user, because the more complex the components structure for the element is, the more time it will take to render. Rendering takes computer resources, and time, and the UI is blocked until everything is ready (if you don’t use web workers).
Usually, we want to display N amount of items until the user doesn’t want to see more. In the following article, I will present the solution I started to implement for custom pagination and infinite scroll.
The Usual Solution
If you work with Angular, you most likely work with Angular Material too, When it comes to tables and pagination, we tend to use MatTable and MatPaginator.
The problem with MatPaginator is that… it is just ugly 🤮 . There are solutions to use Rendere2 to change its look (I will create an article about it in the future), however nowadays most applications are adopting the infinite scroll solution (example: go to youtube.com and start scrolling).
The Problem
Let’s say we make an API call to the server and receive back 10 000 elements, as follows:
import { Observable, of } from 'rxjs';
export interface DummyData {
id: number;
firstName: string;
// ...
}
export const dummyDataObs$: Observable<DummyData[]> = of(
[...Array(10_000).keys()].map((index) => ({
id: index,
firstName: `firstName_${index}`,
// ...
}))
);
We want to display this data in MatTable (it is not required to use material table I just find it convenient) and we end up with the following code:
// imports
@Component({
selector: 'app-simple-table',
styleUrls: ['./simple-table.component.css'],
standalone: true,
imports: [/* ... */],
template: `
<table mat-table [dataSource]="dataSourceSignal()" [trackBy]="identity">
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef>Id</th>
<td mat-cell *matCellDef="let row">
{{ row.id }}
</td>
</ng-container>
<ng-container matColumnDef="firstName">
<th mat-header-cell *matHeaderCellDef>First Name</th>
<td mat-cell *matCellDef="let row">
{{ row.firstName }}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table>
`
})
export class SimpleTableComponent {
@Input({ required: true }) set dummyData(data: DummyData[]) {
this.dummyDataSignal.set(data);
this.limitSignal.set(30);
}
// internal collection of DummyData
private dummyDataSignal = signal<DummyData[]>([]);
// how many elements I want to display
private limitSignal = signal<number>(30);
dataSourceSignal = computed(() => {
// slice data to display only portion of them
const data = this.dummyDataSignal().slice(0, this.limitSignal());
// create correct data structure
return new MatTableDataSource<DummyData>(data);
});
displayedColumns: string[] = ['id', 'firstName'];
// tracking indentity for rendering
identity: TrackByFunction<DummyData> = (_, item: DummyData) => item.id;
}
In the above example we have a limitSignal
signal which has an initial value of 30 elements. Every time we either increase the limitSignal
value, to display additional elements or we receive a new collection of data saved into dummyDataSignal
we compute
a new dataSourceSignal
and pass its value into the table [dataSource]="dataSourceSignal()"
.
Everything is fine, except the question is, how will we know when the user scrolled to the end of the table to display more elements?
Creating a Scrolling Directive
The best possible scenario I could come up with is to create an appScrollNearEnd
directive, that we can attach to any element and it will emit a value to the parent component if the user scrolled to the end of the page.
NOTE: Because we implement an infinite scroll, the solution will only work if the table is the last component in the page. This is usually the case as if you once have an infinite scroll attached to anything, it is most likely your last element, however, I just wanted to highlight this, because the computation involves accessing the page height and calculating whether we approach its end or not.
// imports ...
@Directive({
selector: '[appScrollNearEnd]',
standalone: true,
})
export class ScrollNearEndDirective implements OnInit {
@Output() nearEnd: EventEmitter<void> = new EventEmitter<void>();
/**
* threshold in PX when to emit before page end scroll
*/
@Input() threshold = 120;
private window!: Window;
constructor(private el: ElementRef) {}
ngOnInit(): void {
// save window object for type safety
this.window = window;
}
@HostListener('window:scroll', ['$event.target'])
windowScrollEvent(event: KeyboardEvent) {
// height of whole window page
const heightOfWholePage = this.window.document.documentElement.scrollHeight;
// how big in pixels the element is
const heightOfElement = this.el.nativeElement.scrollHeight;
// currently scrolled Y position
const currentScrolledY = this.window.scrollY;
// height of opened window - shrinks if console is opened
const innerHeight = this.window.innerHeight;
/**
* the area between the start of the page and when this element is visible
* in the parent component
*/
const spaceOfElementAndPage = heightOfWholePage - heightOfElement;
// calculated whether we are near the end
const scrollToBottom =
heightOfElement - innerHeight - currentScrolledY + spaceOfElementAndPage;
// if the user is near end
if (scrollToBottom < this.threshold) {
this.nearEnd.emit();
}
}
}
The inspiration for the above code came from the Stack Overflow thread - How to detect scroll to bottom of html element. Every time the user scrolls to the page the windowScrollEvent
function will be triggered. So how it works?
- Get the height of the whole page -
heightOfWholePage
- Get the height of the element to which the directive is attached -
heightOfElement
- Get the current Y scroll position (change by scrolling) -
currentScrolledY
- Get the height of the viewed window (constant value, but shrinks if dev tools is open) -
innerHeight
- Calculate the space between the element to which the directive is attached and the start of the page (constant value) -
spaceOfElementAndPage
- Calculate
scrollToBottom
by subtracting from the table’s height the window size and the scrolled position, but adding back the space between the table and the start of the page, otherwise, you would get negative values
We can import the ScrollNearEndDirective
to our SimpleTableComponent
, attach it to the table and display more elements, every time nearEnd
emits:
// imports
@Component({
selector: 'app-simple-table',
styleUrls: ['./simple-table.component.css'],
standalone: true,
imports: [/* ... */],
template: `
<div style="height: 50px;">this is spaceOfElementAndPage = 50</div>
<table appScrollNearEnd (nearEnd)="onNearEndScroll()"
mat-table [dataSource]="dataSourceSignal()" [trackBy]="identity">
<!-- content of the table -->
</table>
`
})
export class SimpleTableComponent {
private limitSignal = signal<number>(30);
// ... Previous logic
// increase the number of displayed items
onNearEndScroll(): void {
this.limitSignal.update((val) => val + this.defaultValue);
}
}
The final result is below and the code is available on stackblitz if you want to play with it.
Top comments (4)
Nice, article!
Addicionally if you use fromEvent to transform event listener in observable add usage of passive event better performance in scroll.
Note : If use document or window SSR not working correctly should add DOCUMENT token or execute in afterNextRender ( In Angular 17 or higher)
Isn't this window:scroll listener something you would want to run in the angular outer Zone, to ensure change detection doesn't run for every scroll event? :)
short & concise
Thanks.