Virtual scrolling in Angular Grid component

17 Dec 202424 minutes to read

The virtual scrolling feature in the Grid allows you to efficiently handle and display a large amount of data without experiencing any performance degradation. It optimizes the rendering process by loading only the visible rows in the Grid viewport, rather than rendering the entire dataset at once. This is particularly useful when dealing with datasets that contain thousands of records.

To enable virtualization in the Grid, you need to inject the VirtualScrollService. This service is responsible for managing the virtual scrolling behavior and optimizing the rendering of data to enhance performance.

Row virtualization

Row virtualization is a feature in the Syncfusion Grid that allows you to load and render rows only in the content viewport. It provides an alternative way of paging where data is loaded dynamically while scrolling vertically, rather than loading all the data at once. This is particularly useful when dealing with large datasets, as it improves the performance and reduces the initial load time.

To set up row virtualization, you need to define the enableVirtualization property as true and specify the content height using the height property in the Grid configuration.

The number of records displayed in the Grid is implicitly determined by the height of the content area. Additionally, you have an option to explicitly define the visible number of records using the pageSettings.pageSize property. The loaded data will be cached and reused when needed in the future.

The following example enable row virtualization using enableVirtualization property.

import { NgModule } from '@angular/core'
import { BrowserModule } from '@angular/platform-browser'
import { GridModule, PageService, ToolbarService, EditService } from '@syncfusion/ej2-angular-grids'



import { Component, OnInit } from '@angular/core';
import { VirtualScrollService } from '@syncfusion/ej2-angular-grids';
import { PageSettingsModel, EditSettingsModel, ToolbarItems } from '@syncfusion/ej2-angular-grids';

const names = ['TOM', 'Hawk', 'Jon', 'Chandler', 'Monica', 'Rachel', 'Phoebe', 'Gunther', 'Ross', 'Geller', 'Joey', 'Bing', 'Tribbiani',
 'Janice', 'Bong', 'Perk', 'Green', 'Ken', 'Adams'];
const hours = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const designation = ['Manager', 'Engineer 1', 'Engineer 2', 'Developer', 'Tester'];
const status = ['Completed', 'Open', 'In Progress', 'Review', 'Testing']
const data = (count: any) => {
    const result = [];
    for (let i = 0; i < count; i++) {
        result.push({
          TaskID: i + 1,
          Engineer: names[Math.round(Math.random() * names.length)] || names[0],
          Designation: designation[Math.round(Math.random() * designation.length)] || designation[0],
          Estimation: hours[Math.round(Math.random() * hours.length)] || hours[0],
          Status: status[Math.round(Math.random() * status.length)] || status[0]
        });
    }
    return result;
};

@Component({
imports: [
        
        GridModule
    ],

providers: [PageService, ToolbarService, EditService, VirtualScrollService],
standalone: true,
    selector: 'app-root',
    template: `<ejs-grid [dataSource]='data' height=300 [enableVirtualization]=true [pageSettings]='options' [editSettings]='editSettings' [toolbar]='toolbar'>
                <e-columns>
                    <e-column field='TaskID' headerText='Task ID' textAlign='Right' width=100 isPrimaryKey='true' [validationRules]='rules'></e-column>
                    <e-column field='Engineer' width=100></e-column>
                    <e-column field='Designation' width=100 editType='dropdownedit' [validationRules]='rules'></e-column>
                    <e-column field='Estimation' textAlign='Right' width=100 editType='numericedit' [validationRules]='rules'></e-column>
                    <e-column field='Status' width=100 editType='dropdownedit'></e-column>
                </e-columns>
                </ejs-grid>`
})
export class AppComponent implements OnInit {

    public data?: object[];
    public options?: PageSettingsModel;
    public rules: object = { required: true };
    public editSettings?: EditSettingsModel;
    public toolbar?: ToolbarItems[];
    ngOnInit(): void {
        this.editSettings = { allowEditing: true, allowAdding: true, allowDeleting: true, mode: 'Normal' };
        this.toolbar = ['Add', 'Edit', 'Delete', 'Update', 'Cancel'];
        this.data = data(5000);
        this.options = { pageSize: 50 };
    }
}
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app.component';
import 'zone.js';
bootstrapApplication(AppComponent).catch((err) => console.error(err));

Limitations

  • Row virtual scrolling is not compatible with the following feature
    1. Batch editing
    2. Detail template
    3. Row template
    4. Rowspan
    5. Autofill
    6. Hierarchy grid
  • When row virtual scrolling is activated, compatibility for copy-paste and drag-and-drop operations is limited to the data items visible in the current viewport of the grid.
  • The cell-based selection is not supported for row virtual scrolling.
  • Using different row heights with a template column, when the template height differs for each row, is not supported.
  • Group expand and collapse state will not be persisted for remote data.
  • Due to the element height limitation in browsers, the maximum number of records loaded by the Grid is limited by the browser capability.
  • The height of the grid content is calculated using the row height and total number of records in the data source and hence features which changes row height such as text wrapping are not supported.
  • If you want to increase the row height to accommodate the content then you can specify the row height as below to ensure all the table rows are in same height.

      .e-grid .e-row {
          height: 2em;
      }
  • Since data is virtualized in grid, the aggregated information and total group items are displayed based on the current view items. To get these information regardless of the view items, refer to the Group with paging topic.
  • It is necessary to set a static height for the component or its parent container when using row virtualization. The 100% height will work only if the component height is set to 100%, and its parent container has a static height.

Column virtualization

Column virtualization feature in the Syncfusion Grid that allows you to optimize the rendering of columns by displaying only the columns that are currently within the viewport. It allows horizontal scrolling to view additional columns. This feature is particularly useful when dealing with grids that have a large number of columns, as it helps to improve the performance and reduce the initial loading time.

To enable column virtualization, you need to set the enableColumnVirtualization property of the Grid to true. This configuration instructs the Grid to only render the columns that are currently visible in the viewport.

The following example enable column virtualization using enableColumnVirtualization property.

import { NgModule } from '@angular/core'
import { BrowserModule } from '@angular/platform-browser'
import { GridModule, PageService, ToolbarService, EditService } from '@syncfusion/ej2-angular-grids'



import { Component, OnInit } from '@angular/core';
import { VirtualScrollService } from '@syncfusion/ej2-angular-grids';
import { PageSettingsModel, EditSettingsModel, ToolbarItems } from '@syncfusion/ej2-angular-grids';

let virtualData: any = [];
let names: string[] = ['hardire', 'abramjo01', 'aubucch01', 'Hook', 'Rumpelstiltskin', 'Belle', 'Emma', 'Regina', 'Aurora', 'Elsa', 'Anna',
                      'Snow White', 'Prince Charming', 'Cora', 'Zelena', 'August', 'Mulan', 'Graham', 'Discord', 'Will', 'Robin Hood',
                      'Jiminy Cricket', 'Henry', 'Neal', 'Red', 'Aaran', 'Aaren', 'Aarez', 'Aarman', 'Aaron', 'Aaron-James', 'Aarron',
                       'Aaryan', 'Aaryn', 'Aayan', 'Aazaan', 'Abaan', 'Abbas', 'Abdallah', 'Abdalroof', 'Abdihakim', 'Abdirahman',
                     'Abdisalam', 'Abdul', 'Abdul-Aziz', 'Abdulbasir', 'Abdulkadir', 'Abdulkarem', 'Abdulkhader', 'Abdullah',
                     'Abdul-Majeed', 'Abdulmalik', 'Abdul-Rehman', 'Abdur', 'Abdurraheem', 'Abdur-Rahman', 'Abdur-Rehmaan', 'Abel',
                     'Abhinav', 'Abhisumant', 'Abid', 'Abir', 'Abraham', 'Abu', 'Abubakar', 'Ace', 'Adain', 'Adam', 'Adam-James',
                    'Addison', 'Addisson', 'Adegbola', 'Adegbolahan', 'Aden', 'Adenn', 'Adie', 'Adil', 'Aditya', 'Adnan', 'Adrian',
                    'Adrien', 'Aedan', 'Aedin', 'Aedyn', 'Aeron', 'Afonso', 'Ahmad', 'Ahmed', 'Ahmed-Aziz', 'Ahoua', 'Ahtasham',
                     'Aiadan', 'Aidan', 'Aiden', 'Aiden-Jack', 'Aiden-Vee'];

function dataSource(): void {
    for (let i = 0; i < 5000; i++) {
        virtualData.push({
            SNo: i + 1,
            FIELD1: names[Math.floor(Math.random() * names.length)],
            FIELD2: 1967 + (i % 10), FIELD3: Math.floor(Math.random() * 200),
            FIELD4: Math.floor(Math.random() * 100), FIELD5: Math.floor(Math.random() * 2000), FIELD6: Math.floor(Math.random() * 1000),
            FIELD7: Math.floor(Math.random() * 100), FIELD8: Math.floor(Math.random() * 10), FIELD9: Math.floor(Math.random() * 10),
            FIELD10: Math.floor(Math.random() * 100), FIELD11: Math.floor(Math.random() * 100), FIELD12: Math.floor(Math.random() * 1000),
            FIELD13: Math.floor(Math.random() * 10), FIELD14: Math.floor(Math.random() * 10), FIELD15: Math.floor(Math.random() * 1000),
            FIELD16: Math.floor(Math.random() * 200), FIELD17: Math.floor(Math.random() * 300), FIELD18: Math.floor(Math.random() * 400),
            FIELD19: Math.floor(Math.random() * 500), FIELD20: Math.floor(Math.random() * 700), FIELD21: Math.floor(Math.random() * 800),
            FIELD22: Math.floor(Math.random() * 1000), FIELD23: Math.floor(Math.random() * 2000), FIELD24: Math.floor(Math.random() * 150),
            FIELD25: Math.floor(Math.random() * 1000), FIELD26: Math.floor(Math.random() * 100), FIELD27: Math.floor(Math.random() * 400),
            FIELD28: Math.floor(Math.random() * 600), FIELD29: Math.floor(Math.random() * 500), FIELD30: Math.floor(Math.random() * 300),
        });
    }
}
dataSource();
@Component({
imports: [
        
        GridModule
    ],

providers: [PageService, ToolbarService, EditService, VirtualScrollService],
standalone: true,
    selector: 'app-root',
    template: `<ejs-grid [dataSource]='data' height=300 [enableColumnVirtualization]=true
                [pageSettings]='options' [editSettings]='editSettings' [toolbar]='toolbar'>
                <e-columns>
                    <e-column field='SNo' headerText='S.No' width=140 isPrimaryKey='true' [validationRules]='rules'></e-column>
                    <e-column field='FIELD1' headerText='Player Name' width=140 editType='dropdownedit' [validationRules]='rules'></e-column>
                    <e-column field='FIELD2' headerText='Year' width=120 textAlign='Right'></e-column>
                    <e-column field='FIELD3' headerText='Stint' width=120 textAlign='Right'></e-column>
                    <e-column field='FIELD4' headerText='TMID' width=120 textAlign='Right'></e-column>
                    <e-column field='FIELD5' headerText='LGID' width=120 textAlign='Right'></e-column>
                    <e-column field='FIELD6' headerText='GP' width=120 textAlign='Right'></e-column>
                    <e-column field='FIELD7' headerText='GS' width=120 textAlign='Right'></e-column>
                    <e-column field='FIELD8' headerText='Minutes' width=120 textAlign='Right'></e-column>
                    <e-column field='FIELD9' headerText='Points' width=120 textAlign='Right'></e-column>
                    <e-column field='FIELD10' headerText='oRebounds' width=130 textAlign='Right'></e-column>
                    <e-column field='FIELD11' headerText='dRebounds' width=130 textAlign='Right'></e-column>
                    <e-column field='FIELD12' headerText='Rebounds' width=120 textAlign='Right'></e-column>
                    <e-column field='FIELD13' headerText='Assists' width=120 textAlign='Right'></e-column>
                    <e-column field='FIELD14' headerText='Steals' width=120 textAlign='Right'></e-column>
                    <e-column field='FIELD15' headerText='Blocks' width=120 textAlign='Right'></e-column>
                    <e-column field='FIELD16' headerText='Turnovers' width=130 textAlign='Right'></e-column>
                    <e-column field='FIELD17' headerText='PF' width=130 textAlign='Right'></e-column>
                    <e-column field='FIELD18' headerText='fgAttempted' width=150 textAlign='Right'></e-column>
                    <e-column field='FIELD19' headerText='fgMade' width=120 textAlign='Right'></e-column>
                    <e-column field='FIELD20' headerText='ftAttempted' width=150 textAlign='Right'></e-column>
                    <e-column field='FIELD21' headerText='ftMade' width=120 textAlign='Right'></e-column>
                    <e-column field='FIELD22' headerText='ThreeAttempted' width=150 textAlign='Right'></e-column>
                    <e-column field='FIELD23' headerText='ThreeMade' width=130 textAlign='Right'></e-column>
                    <e-column field='FIELD24' headerText='PostGP' width=120 textAlign='Right'></e-column>
                    <e-column field='FIELD25' headerText='PostGS' width=120 textAlign='Right'></e-column>
                    <e-column field='FIELD26' headerText='PostMinutes' width=120 textAlign='Right'></e-column>
                    <e-column field='FIELD27' headerText='PostPoints' width=130 textAlign='Right'></e-column>
                    <e-column field='FIELD28' headerText='PostoRebounds' width=130 textAlign='Right'></e-column>
                    <e-column field='FIELD29' headerText='PostdRebounds' width=130 textAlign='Right'></e-column>
                    <e-column field='FIELD30' headerText='PostRebounds' width=130 textAlign='Right' editType='numericedit' [validationRules]='rules'></e-column>
                </e-columns>
                </ejs-grid>`
})
export class AppComponent implements OnInit {

    public data?: object[];
    public options?: PageSettingsModel;
    public editSettings?: EditSettingsModel;
    public toolbar?: ToolbarItems[];
    public rules: object = { required: true };
    ngOnInit(): void {
        this.editSettings = { allowEditing: true, allowAdding: true, allowDeleting: true, mode: 'Normal' };
        this.toolbar = ['Add', 'Edit', 'Delete', 'Update', 'Cancel'];
        this.data = virtualData;
        this.options = { pageSize: 50 };
    }
}
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app.component';
import 'zone.js';
bootstrapApplication(AppComponent).catch((err) => console.error(err));

Column’s width is required for column virtualization. If column’s width is not defined then Grid will consider its value as 200px.

Limitations

  • While using column virtual scrolling, column width should be in pixel. Percentage values are not accepted.
  • Selected column details are only retained within the viewport. When the next set of columns is loaded, the selection for previously visible columns is lost.
  • The cell selection is not supported for column virtual scrolling.
  • The Ctrl + Home and Ctrl + End keys are not supported when using column virtual scrolling.
  • The following features are compatible with column virtualization and work within the viewport:
    1. Column resizing
    2. Column reordering
    3. Column chooser
    4. Auto-fit
    5. Print
    6. Clipboard
    7. Column menu - Column chooser, AutofitAll
  • Column virtual scrolling is not compatible with the following feature
    1. Grouping
    2. Colspan
    3. Batch editing
    4. Column with infinite scrolling
    5. Stacked header
    6. Row template
    7. Detail template
    8. Hierarchy grid
    9. Autofill
    10. Column chooser

Browser height limitation in virtual scrolling and solution

You can load millions of records in the Grid by using virtual scrolling, where the grid loads and renders rows on-demand while scrolling vertically. As a result, Grid lightens the browser’s load by minimizing the DOM elements and rendering elements visible in the viewport. The height of the grid is calculated using the Total Records Count * Row Height property.

The browser has some maximum pixel height limitations for the scroll bar element. The content placed above the maximum height can’t be scrolled if the element height is greater than the browser’s maximum height limit. The browser height limit affects the virtual scrolling of the grid. When a large number of records are bound to the Grid, it can only display the records until the maximum height limit of the browser. Once the browser’s height limit is reached while scrolling, the user won’t able to scroll further to view the remaining records.

For example, if the row height is set as 30px and the total record count is 1000000(1 million), then the height of the grid element will be 30,000,000 pixels. In this case, the browser’s maximum height limit for a div is about 22,369,600 (The maximum pixel height limitation differs for different browsers). The records above the maximum height limit of the browser can’t be scrolled.

This height limitation is not related to the Grid component. It fully depends on the default behavior of the browser. The same issue is reproduced in the normal HTML table too.

The following image illustrates the height limitation issue of a normal HTML table in different browsers (Chrome and Firefox).

Browser height limitation in HTML table

Grid component also faced the same issue as mentioned in the below image.

Grid with browser height limitation

The Grid has an option to overcome this limitation of the browser in the following ways.

Solution 1: Using external buttons

You can prevent the height limitation problem in the browser when scrolling through millions of records by loading the segment of data through different strategy.

In the following sample, Grid is rendered with a large number of records(nearly 2 million). Here, you can scroll 0.5 million records at a time in Grid. Once you reach the last page of 0.5 million records, the Load Next Set button will be shown at the bottom of the Grid. By clicking that button, you can view the next set of 0.5 million records in Grid. Also, the Load Previous Set button will be shown at the top of the Grid to load the previous set of 0.5 million records.

Let’s see the step by step procedure for how we can overcome the limitation in the Syncfusion Grid component.

  1. Create a custom adaptor by extending UrlAdaptor and binding it to the grid DataSource property. In the processQuery method of the custom adaptor, we handled the Skip query based on the current page set to perform the data operation with whole records on the server.

         class CustomUrlAdaptor extends UrlAdaptor {
             processQuery(args) {
                 if (arguments[1].queries) {
                     for (const i = 0; i < arguments[1].queries.length; i++) {
                         if (arguments[1].queries[i].fn === 'onPage') {
                             // pageSet - defines the number of segments going to split the 2million records. In this example 0.5 million records are considered for each set so the pageSet is 1, 2, 3 and 4.
                             // maxRecordsPerPageSet – In this example the value is define as 0.5 million.
    
                             // gridPageSize – the pageSize defined in the Grid as pageSettings->pageSize property
    
                             // customize the pageIndex based on the current pageSet (It send the skip query including the previous pageSet ) so that the other operations performed for total 2millions records instead of 0.5 million alone.
                             arguments[1].queries[i].e.pageIndex = (((pageSet - 1) * maxRecordsPerPageSet) / gridPageSize) + arguments[1].queries[i].e.pageIndex;
                         }
                     }
                 }
                 let original = super.processQuery.apply(this, arguments);
                 return original;
             }
         }
         this.data = new DataManager({
             adaptor: new CustomUrlAdaptor(),
             url: "Home/UrlDatasource"
         });
  2. Render the grid by define the following features.

         <ejs-grid #grid [dataSource]='data' [enableVirtualization]='true' [pageSettings]='pageSettings' [height]='360' (beforeDataBound)='beforeDataBound($event)' >
             <e-columns>
                 <e-column field='OrderID' headerText='Order ID' textAlign='Right' width=100></e-column>
                 ......
                 ......  
             </e-columns>
         </ejs-grid>
  3. In the beforeDataBound event, we set the args.count as 0.5 million to perform scrolling with 0.5 million records and all the data operations are performed with whole records which is handled using the custom adaptor. And also particular segment records count is less than 0.5 million means it will directly assigned the original segmented count instead of 0.5 million.

         beforeDataBound(args) {
             // storing the total records count which means 2 million records count
             totalRecords = args.count;
    
             // change the count with respect to maxRecordsPerPageSet (maxRecordsPerPageSet = 500000)
             args.count = args.count - ((pageSet - 1) * maxRecordsPerPageSet) > maxRecordsPerPageSet ?maxRecordsPerPageSet : args.count - ((pageSet - 1) * maxRecordsPerPageSet);
         }
  4. Render “Load Next Set” button and “Load Previous Set” button at bottom and top of the grid component.

         <button ejs-button cssClass="e-info prevbtn" (onClick)="prevBtnClick($event)" content="Load Previous Set..."></button>
    
         <ejs-grid #grid [dataSource]='data' [enableVirtualization]='true' [pageSettings]='pageSettings' [height]='360' (beforeDataBound)='beforeDataBound($event)' >
             <e-columns>
                 <e-column field='OrderID' headerText='Order ID' textAlign='Right' width=100></e-column>
                 ......
                 ......  
             </e-columns>
         </ejs-grid>
    
         <button ejs-button cssClass="e-info nxtbtn" (onClick)="nxtBtnClick($event)" content="Load Next Set..."></button>
  5. While click on the Load Next Set / Load Previous Set button corresponding page data set is loaded to view remaining records of total 2 millions records after doing some simple calculation.

         // Triggered when clicking the Previous/ Next button.
         prevNxtBtnClick(args) {
             if (grid.element.querySelector('.e-content') && grid.element.querySelector('.e-content').getAttribute('aria-busy') === 'false') {
                 // Increase/decrease the pageSet based on the target element.
                 pageSet = args.target.classList.contains('prevbtn') ? --pageSet : ++pageSet;
                 this.rerenderGrid(); // Re-render the Grid component.
             }
         }

Prevent browser height limitation

If you perform grid actions such as filtering, sorting, etc., after scrolling through the 0.5 million data, the Grid performs those data actions with the whole records, not just the current loaded 0.5 million data.

Solution 2: Using RowHeight property

You can reduce the row height using the rowHeight property of the Grid. It will reduce the overall height to accommodate more rows. But this approach optimizes the limitation, but if the height limit is reached after reducing row height also, you have to opt for the previous solution or use paging.

In the following image, you can see how many records will be scrollable when setting rowHeight to “36px” and “30px”.

Row Height

Solution 3: Using paging instead of virtual scrolling

Similar to virtual scrolling, the paging feature also loads the data in an on-demand concept. Pagination is also compatible with all the other features(Grouping, Editing, etc.) in Grid. So, use the paging feature instead of virtual scrolling to view a large number of records in the Grid without any kind of performance degradation or browser height limitation.

See also