Introduction
Handling large lists efficiently is a common challenge in web applications. Standard tables can cause performance issues when rendering thousands of rows. Virtual scrolling solves this by rendering only the visible portion of the list, significantly improving performance.
But what if we need to display different types of data using the same virtual scroll logic? Without a generic approach, we'd end up copy-pasting table implementations, leading to redundant code.
This article introduces a Generic Virtual Scroll Table in Angular, which supports different data structures while maintaining a clean and reusable architecture.
The Problem: Avoiding Code Duplication
Imagine a scenario where different modules in an application require tables for displaying:
- Users
- Orders
- Products
- Custom Fields
Each table shares the same core functionality:
- Fetching paginated data
- Virtual scrolling
- Item selection
- Handling empty states
A common mistake is to create separate table components for each entity, leading to repeated logic and high maintenance costs. If a bug is found in one table, we have to fix it in multiple places.
To solve this, we need a generic solution—one component that can handle any type of list item.
The Solution: A Generic Virtual Scroll Table
We achieve this by implementing a base component that can display any type of data while keeping business logic separate in a dedicated service.
Step 1: Define a Generic Interface for Table Items
Every item displayed in our table must have a unique identifier. We define a generic interface:
export interface VirtualScrollTableItem {
id: number;
}
This ensures that all items used in the table follow a consistent structure.
Step 2: Create the Generic Table Component
The core of our solution is the VirtualScrollTableComponent
, which acts as a template component for any virtual scroll table.
IMPORTANT: @ContentChild('itemTemplate', { static: true }) public itemTemplateRef: any; - makes sure we can have any content we want as an item for the row
@Component({
selector: 'virtual-scroll-table',
templateUrl: './virtual-scroll-table.component.html',
styleUrls: ['./virtual-scroll-table.component.less'],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None
})
export class VirtualScrollTableComponent<T extends VirtualScrollTableItem> implements OnInit {
@HostBinding('class.virtual-scroll-table') public hostCls = true;
// allows as to have any generic item displayed here
@ContentChild('itemTemplate', { static: true }) public itemTemplateRef: any;
public readonly items$: Observable<T[]>;
public readonly isEmpty$: Observable<boolean>;
public readonly loading$: Observable<boolean>;
public readonly selectedItems$: BehaviorSubject<Record<number, T>>;
@ViewChild(CdkVirtualScrollViewport) public viewport: CdkVirtualScrollViewport;
// here we rely on the service being provided in viewProviders
// of parent component
constructor(@SkipSelf() @Host() private virtualScrollTableService: VirtualScrollTableService<T>) {
this.items$ = this.virtualScrollTableService.items$;
this.isEmpty$ = this.virtualScrollTableService.isEmpty$;
this.loading$ = this.virtualScrollTableService.loading$;
this.selectedItems$ = this.virtualScrollTableService.selectedItems$;
}
public ngOnInit(): void {
this.virtualScrollTableService.loadItems();
}
public onRequestNextPage(): void {
this.virtualScrollTableService.onRequestNextPage(this.viewport);
}
public selectionChanged(selected: boolean, item: T): void {
this.virtualScrollTableService.selectionChanged(selected, item);
}
}
This component is completely independent of data type, making it reusable for any kind of list.
Template looks like this, as generic as possible
<ng-container *ngIf="(isEmpty$ | async) === false; else emptyPage">
<div class="virtual-scroll-table_row--sticky" *ngIf="(items$ | async).length > 0">
<div class="virtual-scroll-table_row--sticky_item" *ngFor="let item of stickyRowConfig">{{ item | translate }}</div>
</div>
<cdk-virtual-scroll-viewport
[itemSize]="itemSize"
[minBufferPx]="60"
[maxBufferPx]="120"
(scrolledIndexChange)="onRequestNextPage()">
<div class="virtual-scroll-table_row" *cdkVirtualFor="let item of items$ | async; index as i; trackBy: trackByFn">
<mat-checkbox
#checkbox
class="virtual-scroll-table_row_checkbox"
*ngIf="selectionEnabled"
[checked]="!!(selectedItems$ | async)?.[item.id]"
(click)="selectionChanged(checkbox.checked, item)">
</mat-checkbox>
<div class="virtual-scroll-table_row_content">
<ng-container [ngTemplateOutlet]="itemTemplateRef" [ngTemplateOutletContext]="{ $implicit: item, index: i }"></ng-container>
</div>
</div>
<loading-mask *ngIf="(loading$ | async) && (items$ | async).length > 0" class="virtual-scroll-table_row-loader"></loading-mask>
</cdk-virtual-scroll-viewport>
</ng-container>
<loading-mask
*ngIf="(loading$ | async) && (items$ | async).length === 0"
class="virtual-scroll-table_fullscreen-loader">
</loading-mask>
<ng-template #emptyPage>
<empty-page [config]="emptyPageConfig"></empty-page>
</ng-template>
Step 3: Define a Base Service for Managing Data
Instead of handling data inside the component, we delegate the logic to a service that follows a common interface.
export abstract class VirtualScrollTableService<T extends VirtualScrollTableItem> {
public readonly items$: Observable<T[]>;
public readonly isEmpty$: Observable<boolean>;
public readonly loading$: Observable<boolean>;
public readonly selectedItems$ = new BehaviorSubject<Record<number, T>>({});
public abstract loadItems(): void;
public abstract onRequestNextPage(viewport: CdkVirtualScrollViewport): void;
public abstract selectionChanged?(selected: boolean, item: T): void;
public abstract clearSelection?(): void;
}
This forces all virtual table services to follow the same contract while allowing flexibility for different data sources.
Step 4: Implement a Domain-Specific Service
We start be creating wrapper component
@Component({
selector: 'custom-fields-virtual-scroll-table',
imports: [CommonModule, VirtualScrollTableComponent, CustomFieldsTableRowComponent],
templateUrl: './custom-fields-virtual-scroll-table.component.html',
styleUrls: ['./custom-fields-virtual-scroll-table.component.less'],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
viewProviders: [
{
provide: VirtualScrollTableService,
useFactory: (customFieldStore: CustomFieldsStore, store: Store<any>) =>
new CustomFieldsVirtualScrollTableService(customFieldStore, store),
deps: [CustomFieldsStore, Store<any>]
}
]
})
export class CustomFieldsVirtualScrollTableComponent{ }
As you can see it is empty, which is very good, as it means that we did a good job of extracting common logic
Then,, if we want a table for custom fields, we create a dedicated service:
@Injectable()
export class CustomFieldsVirtualScrollTableService
extends ReactiveService
implements VirtualScrollTableService<CustomField> {
public readonly items$: Observable<CustomField[]>;
public readonly isEmpty$: Observable<boolean>;
public readonly loading$: Observable<boolean>;
public readonly selectedItems$ = new BehaviorSubject<Record<number, CustomField>>({});
constructor(public customFieldsStore: CustomFieldsStore, private store: Store<any>) {
super();
this.items$ = combineLatest([
this.store.select(selectCustomFieldEntities),
this.customFieldsStore.customFieldIds$
]).pipe(map(([customFields, customFieldIds]) => customFieldIds.map(id => customFields[id])));
this.isEmpty$ = this.customFieldsStore.hasCustomFields$.pipe(map(hasFields => !hasFields));
this.loading$ = this.customFieldsStore.loading$;
}
public loadItems(): void {
this.customFieldsStore.loadCustomFields$();
}
public onRequestNextPage(viewport: CdkVirtualScrollViewport): void {
this.customFieldsStore.loadNextBatch$();
}
}
Final piece
<virtual-scroll-table>
<ng-template #itemTemplate let-customField let-id="index">
<custom-fields-table-row
[customField]="customField">
</custom-fields-table-row>
</ng-template>
</virtual-scroll-table>
Conclusion
Using a Generic Virtual Scroll Table in Angular eliminates repetitive code and provides a scalable, maintainable, and efficient way to handle large lists. By separating UI concerns from data logic, this approach ensures long-term maintainability and flexibility.
If you're building tables in Angular, don’t fall into the copy-paste trap—invest in a reusable architecture today!
Top comments (0)