DEV Community

Aleks Onyshko
Aleks Onyshko

Posted on

Building a Generic Virtual Scroll Table in Angular: A Scalable Approach

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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>

Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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{ }

Enter fullscreen mode Exit fullscreen mode

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$();
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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)